@overpunch/opszstepper 1.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +212 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +160 -0
- package/dist/index.js +114 -0
- package/dist/opszstepper.webflow.min.js +1 -0
- package/package.json +84 -0
package/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# opszStepper
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@overpunch/opszstepper) [](https://opensource.org/licenses/MIT) [](https://github.com/over-punch/type-tools)
|
|
4
|
+
|
|
5
|
+
`font-optical-sizing: auto` only works for variable fonts with an `opsz` axis. opszStepper solves the other case: professional typeface families that ship separate font files for each optical size cut (Micro, Text, Display) with no axis at all. It automatically swaps the correct cut onto an element as its `font-size` changes.
|
|
6
|
+
|
|
7
|
+
**[opszstepper.com](https://opszstepper.com)** · [npm](https://www.npmjs.com/package/@overpunch/opszstepper) · [GitHub](https://github.com/over-punch/opszStepper)
|
|
8
|
+
|
|
9
|
+
TypeScript · Zero dependencies · React + Vanilla JS
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @overpunch/opszstepper
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
> **Next.js App Router:** this library uses browser APIs. Add `"use client"` to any component file that imports from it.
|
|
26
|
+
|
|
27
|
+
### What are optical cuts?
|
|
28
|
+
|
|
29
|
+
Many professional editorial typefaces ship as a family of separate font files — each drawn specifically for a different size range. Halyard has Halyard Micro (captions and footnotes), Halyard Text (body), and Halyard Display (headlines). Tiempos has Tiempos Fine, Tiempos Text, and Tiempos Headline. Each cut has different contrast, spacing, and stroke weight tuned for its intended size. CSS has no mechanism to switch between them automatically — `font-optical-sizing: auto` only controls the `opsz` axis of a single variable font file. opszStepper fills that gap.
|
|
30
|
+
|
|
31
|
+
Scaling one cut to every size is the problem opszStepper avoids. On the left, a single Display cut is used at all sizes — fine when large, but its high contrast and tight spacing turn spindly and fragile when shrunk. On the right, opszStepper swaps in the cut drawn for each size:
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
### Two modes: family hot-swap or `opsz` axis
|
|
36
|
+
|
|
37
|
+
opszStepper covers both ways type families ship optical sizes:
|
|
38
|
+
|
|
39
|
+
1. **Multi-family hot-swap** *(the primary case)* — separate font files per cut (Halyard Micro / Text / Display). Give each cut a different `family`; opszStepper sets `font-family` to the matching cut as `font-size` crosses each threshold. Every example below uses this mode.
|
|
40
|
+
2. **Single variable font, `opsz` axis** — one variable font with an `opsz` axis (Fraunces, Recursive, Amstelvar). Use the *same* `family` in every cut and add an `opszValue` per cut; opszStepper writes `font-variation-settings: "opsz" <value>` instead of swapping files. Optional `opszMin`/`opszMax` clamp the value to the font's fvar range:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
cuts: [
|
|
44
|
+
{ family: 'Fraunces, serif', maxSize: 13, opszValue: 9, opszMin: 9, opszMax: 144 },
|
|
45
|
+
{ family: 'Fraunces, serif', minSize: 13, maxSize: 28, opszValue: 24, opszMin: 9, opszMax: 144 },
|
|
46
|
+
{ family: 'Fraunces, serif', minSize: 28, opszValue: 72, opszMin: 9, opszMax: 144 },
|
|
47
|
+
]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This steps the axis at discrete thresholds with hysteresis, which is useful when you want explicit control over the `opsz` value per size band rather than the browser's continuous `font-optical-sizing: auto`.
|
|
51
|
+
|
|
52
|
+
### React component
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
import { OpszStepperText } from '@overpunch/opszstepper'
|
|
56
|
+
|
|
57
|
+
<OpszStepperText
|
|
58
|
+
cuts={[
|
|
59
|
+
{ family: 'Halyard Micro, sans-serif', maxSize: 13 },
|
|
60
|
+
{ family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
|
|
61
|
+
{ family: 'Halyard Display, sans-serif', minSize: 28 },
|
|
62
|
+
]}
|
|
63
|
+
>
|
|
64
|
+
Your paragraph text here...
|
|
65
|
+
</OpszStepperText>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### React hook
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
import { useOpszStepper } from '@overpunch/opszstepper'
|
|
72
|
+
|
|
73
|
+
// Inside a React component:
|
|
74
|
+
const ref = useOpszStepper({
|
|
75
|
+
cuts: [
|
|
76
|
+
{ family: 'Halyard Micro, sans-serif', maxSize: 13 },
|
|
77
|
+
{ family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
|
|
78
|
+
{ family: 'Halyard Display, sans-serif', minSize: 28 },
|
|
79
|
+
],
|
|
80
|
+
})
|
|
81
|
+
return <p ref={ref}>{children}</p>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The hook starts a `ResizeObserver` on the element and re-evaluates the active cut each time the element's size changes (which triggers a re-read of `font-size`). It restarts automatically when `cuts.length` or `hysteresis` changes, and cleans up on unmount.
|
|
85
|
+
|
|
86
|
+
### Vanilla JS — with ResizeObserver
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { startOpszStepper } from '@overpunch/opszstepper'
|
|
90
|
+
|
|
91
|
+
const el = document.querySelector('p')
|
|
92
|
+
|
|
93
|
+
const cuts = [
|
|
94
|
+
{ family: 'Halyard Micro, sans-serif', maxSize: 13 },
|
|
95
|
+
{ family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
|
|
96
|
+
{ family: 'Halyard Display, sans-serif', minSize: 28 },
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
let stop = startOpszStepper(el, { cuts })
|
|
100
|
+
|
|
101
|
+
// Later — stop the observer and restore original fontFamily:
|
|
102
|
+
// stop()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Vanilla JS — one-shot
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { applyOpszStepper } from '@overpunch/opszstepper'
|
|
109
|
+
|
|
110
|
+
const el = document.querySelector('p')
|
|
111
|
+
|
|
112
|
+
applyOpszStepper(el, {
|
|
113
|
+
cuts: [
|
|
114
|
+
{ family: 'Halyard Micro, sans-serif', maxSize: 13 },
|
|
115
|
+
{ family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
|
|
116
|
+
{ family: 'Halyard Display, sans-serif', minSize: 28 },
|
|
117
|
+
],
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// Later — restore original fontFamily:
|
|
121
|
+
// removeOpszStepper(el)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### TypeScript
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import type { OpszStepperCut, OpszStepperOptions } from '@overpunch/opszstepper'
|
|
128
|
+
|
|
129
|
+
const cuts: OpszStepperCut[] = [
|
|
130
|
+
{ family: 'Tiempos Fine, serif', maxSize: 13 },
|
|
131
|
+
{ family: 'Tiempos Text, serif', minSize: 13, maxSize: 28 },
|
|
132
|
+
{ family: 'Tiempos Headline, serif', minSize: 28 },
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
const opts: OpszStepperOptions = { cuts, hysteresis: 2 }
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Options
|
|
141
|
+
|
|
142
|
+
| Option | Default | Description |
|
|
143
|
+
|--------|---------|-------------|
|
|
144
|
+
| `cuts` | *(required)* | Array of `OpszStepperCut` objects defining each optical size cut and the font-size range it applies to. Each cut has a `family` string (CSS `font-family` value), an optional `minSize` in px (inclusive, default `0`), and an optional `maxSize` in px (exclusive, default `Infinity`). Ranges should be contiguous and non-overlapping — see the cuts configuration guide below |
|
|
145
|
+
| `cut.opszValue` | `undefined` | *(per-cut, `opsz`-axis mode only)* When set, opszStepper writes `font-variation-settings: "opsz" <value>` on the element instead of swapping `family`. Use with a single variable font shared across all cuts — see [Two modes](#two-modes-family-hot-swap-or-opsz-axis) |
|
|
146
|
+
| `cut.opszMin` / `cut.opszMax` | `undefined` | *(per-cut, `opsz`-axis mode only)* Clamp the written `opszValue` to the font's fvar `opsz` axis range. Ignored unless `opszValue` is set |
|
|
147
|
+
| `hysteresis` | `1` | Dead zone in px around each cut boundary. When font-size sits within `hysteresis` px of a threshold, the current cut is held rather than switching. Prevents oscillation when font-size is computed to hover right at a boundary due to sub-pixel rendering or responsive scaling. Increase to `2`–`4` if you observe rapid toggling |
|
|
148
|
+
| `onCutChange` | `undefined` | Callback fired each time the active cut changes. Receives the newly applied `OpszStepperCut`. Useful for logging, analytics, or synchronising sibling elements |
|
|
149
|
+
| `as` | `'p'` | HTML element to render. Accepts any valid React element type, e.g. `'h1'`, `'div'`, `'span'`. *(React component only)* |
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Cuts configuration guide
|
|
154
|
+
|
|
155
|
+
A cut is active when the element's computed `font-size` satisfies `minSize <= fontSize < maxSize`. The bounds are in CSS pixels as returned by `getComputedStyle(el).fontSize`.
|
|
156
|
+
|
|
157
|
+
Structure cuts as contiguous ranges — each `maxSize` should equal the next cut's `minSize`:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
cuts: [
|
|
161
|
+
{ family: 'Halyard Micro, sans-serif', maxSize: 13 }, // 0px – 13px
|
|
162
|
+
{ family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 }, // 13px – 28px
|
|
163
|
+
{ family: 'Halyard Display, sans-serif', minSize: 28 }, // 28px – ∞
|
|
164
|
+
]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
You can omit the smallest cut's `minSize` (defaults to `0`) and the largest cut's `maxSize` (defaults to `Infinity`). If font-size falls outside all defined ranges — which should not happen with a complete contiguous set — the active cut is left unchanged.
|
|
168
|
+
|
|
169
|
+
**Hysteresis at boundaries:** if font-size is `13.4px` and the active cut was Halyard Text (`minSize: 13`), the tool will not switch to Halyard Micro until `fontSize < 13 - hysteresis` (i.e. `< 12` with the default `hysteresis: 1`). Moving in the other direction — Text to Display — requires `fontSize > 28 + hysteresis` (i.e. `> 29`). This prevents flicker when a responsive layout computes font-size to a value that oscillates across a boundary.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## How it works
|
|
174
|
+
|
|
175
|
+
`startOpszStepper` reads the element's computed `font-size` via `getComputedStyle(el).fontSize` and finds the matching cut. It then sets `el.style.fontFamily` to that cut's `family` string, overriding whatever the stylesheet specifies. The original `fontFamily` value is stored in a `WeakMap` keyed by element so it can be restored exactly when `removeOpszStepper` or the stop function is called.
|
|
176
|
+
|
|
177
|
+
A `ResizeObserver` watches the element for size changes. In responsive layouts, `font-size` is typically driven by `clamp()`, viewport units, or container queries — all of which can change as the element or viewport resizes. Each observer callback re-reads `font-size` and applies hysteresis logic before switching cuts, so a cut swap only fires when the size has moved clearly past a threshold.
|
|
178
|
+
|
|
179
|
+
**`document.fonts.load()` is not awaited.** The cut swap is immediate — opszStepper sets `font-family` and the browser handles the font load. If a cut's font file has not yet loaded, the browser will show a fallback until it arrives (standard FOUT behaviour). If you need to eliminate FOUT, preload each cut's font file in the document `<head>` using `<link rel="preload" as="font">`. opszStepper does not manage font loading.
|
|
180
|
+
|
|
181
|
+
**Original fontFamily is saved and restored.** When `removeOpszStepper(el)` or the stop function from `startOpszStepper` is called, the element's `style.fontFamily` is reset to exactly the value it had before the first call. If the element had no inline `fontFamily`, it is restored to an empty string (clearing the inline property, deferring to the stylesheet).
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Dev notes
|
|
186
|
+
|
|
187
|
+
### `next` in root devDependencies
|
|
188
|
+
|
|
189
|
+
`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.
|
|
190
|
+
|
|
191
|
+
The package itself has zero runtime dependencies. Do not remove this entry.
|
|
192
|
+
|
|
193
|
+
### Regenerating the README visuals
|
|
194
|
+
|
|
195
|
+
The hero and comparison images live in `assets/` and are produced by a committed, re-runnable harness in `scripts/` (it renders the three real Cormorant optical cuts in headless Chromium and screenshots each scene). `assets/` and `scripts/` are kept out of the npm tarball — the package ships `dist` only.
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
npm i -D playwright && npx playwright install chromium
|
|
199
|
+
node scripts/capture.mjs # writes assets/hero.png and assets/compare.png
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Bump the `?v=N` cache-buster on the image URLs in this README after regenerating.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Future improvements
|
|
207
|
+
|
|
208
|
+
- **Container query support** — re-evaluate cuts in response to `@container` size changes, not just element resize, for components embedded in container-query layouts where `font-size` is driven by container width
|
|
209
|
+
- **Smooth crossfade** — optionally apply a short CSS `transition: font-family` equivalent using a brief opacity fade between cuts to soften the swap on large editorial pages
|
|
210
|
+
- **Multi-element sync** — a `syncGroup` option to tie multiple elements to the same active cut, so a heading and its pull-quote always use the same optical cut at all times
|
|
211
|
+
- **Font preload hints** — automatically inject `<link rel="preload">` tags for all cut font files on first call, so the browser can fetch them before they are needed
|
|
212
|
+
- **SSR hydration** — detect the correct cut server-side via a CSS custom property or data attribute so the initial render uses the right `font-family` without a post-hydration swap
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const u=require("react"),x=require("react/jsx-runtime"),p=new WeakMap,y=new WeakMap,l=new WeakMap,F=1;function d(t,n){for(let e=0;e<t.length;e++){const s=t[e],i=s.minSize??0,r=s.maxSize??1/0;if(n>=i&&n<r)return e}return-1}function v(t,n,e,s){const i=d(t,n);if(i===-1)return e===-1?i:e;if(e===-1)return i;if(i===e)return e;const r=t[e],a=t[i];if(i>e){const o=(a.minSize??0)+s;return n>o?i:e}else{const o=(r.minSize??0)-s;return n<o?i:e}}function z(t,n){const e=window.scrollY;if(t.style.fontFamily=n.family,n.opszValue!==void 0){const s=n.opszMin??-1/0,i=n.opszMax??1/0,r=Math.min(i,Math.max(s,n.opszValue));t.style.fontVariationSettings=`"opsz" ${r}`}else t.style.fontVariationSettings&&(t.style.fontVariationSettings="");requestAnimationFrame(()=>{Math.abs(window.scrollY-e)>2&&window.scrollTo({top:e,behavior:"instant"})})}function g(t,n){if(typeof window>"u")return;const{cuts:e,onCutChange:s}=n;if(!e||e.length===0)return;const i=parseFloat(getComputedStyle(t).fontSize);if(isNaN(i))return;const r=d(e,i);if(r===-1)return;const a=e[r];p.has(t)||p.set(t,t.style.fontFamily),z(t,a),l.set(t,r),s==null||s(a)}function w(t,n){if(typeof window>"u")return()=>{};const{cuts:e,onCutChange:s}=n,i=n.hysteresis??F;if(!e||e.length===0)return()=>{};const r=y.get(t);r&&r(),p.has(t)||p.set(t,t.style.fontFamily),g(t,n);const a=new ResizeObserver(()=>{const c=parseFloat(getComputedStyle(t).fontSize);if(isNaN(c))return;const S=l.get(t)??-1,f=v(e,c,S,i);f!==S&&f!==-1&&(z(t,e[f]),l.set(t,f),s==null||s(e[f]))});a.observe(t);const o=()=>{a.disconnect();const c=p.get(t);c!==void 0&&(t.style.fontFamily=c,t.style.fontVariationSettings="",p.delete(t)),l.delete(t),y.delete(t)};return y.set(t,o),o}function b(t){const n=y.get(t);if(n){n();return}if(p.has(t)){const e=p.get(t);t.style.fontFamily=e??"",t.style.fontVariationSettings="",p.delete(t),l.delete(t)}}function O(t){const n=u.useRef(null),e=u.useRef(t);e.current=t;const s=u.useRef(null),i=JSON.stringify(t.cuts),{hysteresis:r}=t;return u.useLayoutEffect(()=>{var o;const a=n.current;if(a)return(o=s.current)==null||o.call(s),s.current=w(a,e.current),()=>{var c;(c=s.current)==null||c.call(s),s.current=null}},[i,r]),n}const h=u.forwardRef(function({children:n,as:e="p",cuts:s,hysteresis:i,onCutChange:r,...a},o){const f=O({cuts:s,hysteresis:i,onCutChange:r}),M=u.useCallback(m=>{f.current=m,typeof o=="function"?o(m):o&&(o.current=m)},[o]);return x.jsx(e,{ref:M,...a,children:n})});h.displayName="OpszStepperText";exports.OpszStepperText=h;exports.applyOpszStepper=g;exports.removeOpszStepper=b;exports.startOpszStepper=w;exports.useOpszStepper=O;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { default as default_2 } from 'react';
|
|
2
|
+
import { RefObject } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One-shot application of the correct optical cut for the element's current
|
|
6
|
+
* computed font-size. No ResizeObserver — use when you want manual control.
|
|
7
|
+
* Note: hysteresis is not applied here; use startOpszStepper for hysteresis support.
|
|
8
|
+
*
|
|
9
|
+
* @param el - Target element
|
|
10
|
+
* @param options - OpszStepperOptions
|
|
11
|
+
*/
|
|
12
|
+
export declare function applyOpszStepper(el: HTMLElement, options: OpszStepperOptions): void;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A single optical size cut definition: a font-family string and the font-size
|
|
16
|
+
* range in px over which it should be active.
|
|
17
|
+
*
|
|
18
|
+
* Two usage modes:
|
|
19
|
+
* 1. Multi-family hot-swap (e.g. Halyard Micro / Text / Display):
|
|
20
|
+
* set `family` to the CSS font-family string for each cut. Leave `opszValue` unset.
|
|
21
|
+
* 2. Single variable-font opsz axis (e.g. Recursive, Fraunces, Amstelvar):
|
|
22
|
+
* set `family` to the single variable font family, and set `opszValue` to the
|
|
23
|
+
* `opsz` axis value to apply at this cut. The tool will write
|
|
24
|
+
* `font-variation-settings: "opsz" <value>` on the element.
|
|
25
|
+
* Optionally provide `opszMin` and `opszMax` to clamp the value against the
|
|
26
|
+
* font's fvar axis range.
|
|
27
|
+
*/
|
|
28
|
+
export declare interface OpszStepperCut {
|
|
29
|
+
/**
|
|
30
|
+
* The CSS font-family string for this optical cut.
|
|
31
|
+
* e.g. 'Halyard Display, sans-serif' or '"Tiempos Display", serif'
|
|
32
|
+
* For variable-font opsz mode, use the same family string in all cuts.
|
|
33
|
+
*/
|
|
34
|
+
family: string;
|
|
35
|
+
/** Min font-size in px (inclusive) for this cut to apply. Default: 0 */
|
|
36
|
+
minSize?: number;
|
|
37
|
+
/** Max font-size in px (exclusive) for this cut to apply. Default: Infinity */
|
|
38
|
+
maxSize?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Optional `opsz` axis value to write as `font-variation-settings: "opsz" <value>`.
|
|
41
|
+
* Use this when all cuts share one variable font and you want to drive the opsz axis
|
|
42
|
+
* directly rather than hot-swapping font families.
|
|
43
|
+
* When provided, `font-variation-settings: normal` is written first to clear any
|
|
44
|
+
* inherited axis values before applying the new `opsz` value.
|
|
45
|
+
* Clamped between `opszMin` and `opszMax` when those are supplied.
|
|
46
|
+
*/
|
|
47
|
+
opszValue?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Minimum allowed value for the `opsz` axis (from the font's fvar table).
|
|
50
|
+
* Only used when `opszValue` is set. Clamps the written value from below.
|
|
51
|
+
*/
|
|
52
|
+
opszMin?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Maximum allowed value for the `opsz` axis (from the font's fvar table).
|
|
55
|
+
* Only used when `opszValue` is set. Clamps the written value from above.
|
|
56
|
+
*/
|
|
57
|
+
opszMax?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Options controlling the opszStepper effect */
|
|
61
|
+
export declare interface OpszStepperOptions {
|
|
62
|
+
/**
|
|
63
|
+
* The optical size cuts, ordered from smallest to largest.
|
|
64
|
+
* Each cut defines a font-family string and the font-size range it applies to.
|
|
65
|
+
* Ranges should be contiguous and non-overlapping.
|
|
66
|
+
*
|
|
67
|
+
* @example Multi-family hot-swap
|
|
68
|
+
* cuts: [
|
|
69
|
+
* { family: 'Halyard Micro, sans-serif', maxSize: 13 },
|
|
70
|
+
* { family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
|
|
71
|
+
* { family: 'Halyard Display, sans-serif', minSize: 28 },
|
|
72
|
+
* ]
|
|
73
|
+
*
|
|
74
|
+
* @example Single variable font opsz axis
|
|
75
|
+
* cuts: [
|
|
76
|
+
* { family: 'Fraunces, serif', maxSize: 13, opszValue: 9, opszMin: 9, opszMax: 144 },
|
|
77
|
+
* { family: 'Fraunces, serif', minSize: 13, maxSize: 28, opszValue: 24, opszMin: 9, opszMax: 144 },
|
|
78
|
+
* { family: 'Fraunces, serif', minSize: 28, opszValue: 72, opszMin: 9, opszMax: 144 },
|
|
79
|
+
* ]
|
|
80
|
+
*/
|
|
81
|
+
cuts: OpszStepperCut[];
|
|
82
|
+
/**
|
|
83
|
+
* Hysteresis dead zone in px per threshold. Prevents oscillation when
|
|
84
|
+
* font-size sits exactly at a cut boundary. Only used by startOpszStepper;
|
|
85
|
+
* applyOpszStepper always does a direct cut lookup without hysteresis.
|
|
86
|
+
* Default: 1
|
|
87
|
+
*/
|
|
88
|
+
hysteresis?: number;
|
|
89
|
+
/**
|
|
90
|
+
* Callback fired each time the active cut changes.
|
|
91
|
+
* Receives the new cut that was applied.
|
|
92
|
+
*/
|
|
93
|
+
onCutChange?: (cut: OpszStepperCut) => void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A stop function returned by startOpszStepper. Call it to disconnect the observer and restore the element. */
|
|
97
|
+
export declare type OpszStepperStop = () => void;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Drop-in component that automatically swaps between optical size cuts
|
|
101
|
+
* of a typeface family as the element's font-size changes.
|
|
102
|
+
* Forwards the ref to the root element while also attaching the internal hook ref.
|
|
103
|
+
* Accepts all standard HTML and ARIA attributes.
|
|
104
|
+
*/
|
|
105
|
+
export declare const OpszStepperText: default_2.ForwardRefExoticComponent<OpszStepperTextProps & default_2.RefAttributes<HTMLElement>>;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Props for the OpszStepperText component.
|
|
109
|
+
* Extends OpszStepperOptions plus all standard HTML attributes (including ARIA)
|
|
110
|
+
* so that aria-label, role, tabIndex, id, etc. are accepted and forwarded to the DOM.
|
|
111
|
+
*/
|
|
112
|
+
declare interface OpszStepperTextProps extends OpszStepperOptions, default_2.HTMLAttributes<HTMLElement> {
|
|
113
|
+
/** HTML element to render. Default: 'p' */
|
|
114
|
+
as?: default_2.ElementType;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Restore the element's original fontFamily and disconnect any running
|
|
119
|
+
* ResizeObserver started by startOpszStepper. No-op if never applied.
|
|
120
|
+
*
|
|
121
|
+
* @param el - Element previously passed to startOpszStepper or applyOpszStepper
|
|
122
|
+
*/
|
|
123
|
+
export declare function removeOpszStepper(el: HTMLElement): void;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Start a ResizeObserver-backed optical cut watcher on an element.
|
|
127
|
+
* Re-evaluates the correct cut each time the element's size changes
|
|
128
|
+
* (which can cause font-size to change in responsive designs).
|
|
129
|
+
*
|
|
130
|
+
* Applies the correct cut immediately on first call.
|
|
131
|
+
* If called a second time on the same element, the prior observer is stopped
|
|
132
|
+
* first to avoid orphaned observers.
|
|
133
|
+
*
|
|
134
|
+
* @param el - Target element
|
|
135
|
+
* @param options - OpszStepperOptions
|
|
136
|
+
* @returns A stop function that disconnects the observer and restores the original fontFamily
|
|
137
|
+
*/
|
|
138
|
+
export declare function startOpszStepper(el: HTMLElement, options: OpszStepperOptions): OpszStepperStop;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* React hook that starts an opszStepper observer on the returned ref'd element.
|
|
142
|
+
* Calls startOpszStepper in useLayoutEffect and stores the returned stop function.
|
|
143
|
+
*
|
|
144
|
+
* Re-runs (stops and restarts the observer) whenever any cut's family, minSize, or
|
|
145
|
+
* maxSize changes, or when cuts are added/removed, or when hysteresis changes.
|
|
146
|
+
* A stable JSON serialisation of the cuts array is used as the dependency key so
|
|
147
|
+
* that same-length arrays with different content also trigger a restart.
|
|
148
|
+
*
|
|
149
|
+
* Note: onCutChange is kept in a ref and never used as a dependency — it is always
|
|
150
|
+
* read fresh from optionsRef on each observer callback, so it does not need to be
|
|
151
|
+
* stable across renders.
|
|
152
|
+
*
|
|
153
|
+
* Cleans up on unmount.
|
|
154
|
+
*
|
|
155
|
+
* @param options - OpszStepperOptions
|
|
156
|
+
* @returns A ref to attach to the target element
|
|
157
|
+
*/
|
|
158
|
+
export declare function useOpszStepper(options: OpszStepperOptions): RefObject<HTMLElement | null>;
|
|
159
|
+
|
|
160
|
+
export { }
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useRef as S, useLayoutEffect as z, forwardRef as h, useCallback as M } from "react";
|
|
2
|
+
import { jsx as F } from "react/jsx-runtime";
|
|
3
|
+
const f = /* @__PURE__ */ new WeakMap(), y = /* @__PURE__ */ new WeakMap(), p = /* @__PURE__ */ new WeakMap(), x = 1;
|
|
4
|
+
function d(t, e) {
|
|
5
|
+
for (let n = 0; n < t.length; n++) {
|
|
6
|
+
const s = t[n], i = s.minSize ?? 0, o = s.maxSize ?? 1 / 0;
|
|
7
|
+
if (e >= i && e < o)
|
|
8
|
+
return n;
|
|
9
|
+
}
|
|
10
|
+
return -1;
|
|
11
|
+
}
|
|
12
|
+
function O(t, e, n, s) {
|
|
13
|
+
const i = d(t, e);
|
|
14
|
+
if (i === -1) return n === -1 ? i : n;
|
|
15
|
+
if (n === -1) return i;
|
|
16
|
+
if (i === n) return n;
|
|
17
|
+
const o = t[n], a = t[i];
|
|
18
|
+
if (i > n) {
|
|
19
|
+
const r = (a.minSize ?? 0) + s;
|
|
20
|
+
return e > r ? i : n;
|
|
21
|
+
} else {
|
|
22
|
+
const r = (o.minSize ?? 0) - s;
|
|
23
|
+
return e < r ? i : n;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function g(t, e) {
|
|
27
|
+
const n = window.scrollY;
|
|
28
|
+
if (t.style.fontFamily = e.family, e.opszValue !== void 0) {
|
|
29
|
+
const s = e.opszMin ?? -1 / 0, i = e.opszMax ?? 1 / 0, o = Math.min(i, Math.max(s, e.opszValue));
|
|
30
|
+
t.style.fontVariationSettings = `"opsz" ${o}`;
|
|
31
|
+
} else
|
|
32
|
+
t.style.fontVariationSettings && (t.style.fontVariationSettings = "");
|
|
33
|
+
requestAnimationFrame(() => {
|
|
34
|
+
Math.abs(window.scrollY - n) > 2 && window.scrollTo({ top: n, behavior: "instant" });
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function v(t, e) {
|
|
38
|
+
if (typeof window > "u") return;
|
|
39
|
+
const { cuts: n, onCutChange: s } = e;
|
|
40
|
+
if (!n || n.length === 0) return;
|
|
41
|
+
const i = parseFloat(getComputedStyle(t).fontSize);
|
|
42
|
+
if (isNaN(i)) return;
|
|
43
|
+
const o = d(n, i);
|
|
44
|
+
if (o === -1) return;
|
|
45
|
+
const a = n[o];
|
|
46
|
+
f.has(t) || f.set(t, t.style.fontFamily), g(t, a), p.set(t, o), s == null || s(a);
|
|
47
|
+
}
|
|
48
|
+
function V(t, e) {
|
|
49
|
+
if (typeof window > "u") return () => {
|
|
50
|
+
};
|
|
51
|
+
const { cuts: n, onCutChange: s } = e, i = e.hysteresis ?? x;
|
|
52
|
+
if (!n || n.length === 0) return () => {
|
|
53
|
+
};
|
|
54
|
+
const o = y.get(t);
|
|
55
|
+
o && o(), f.has(t) || f.set(t, t.style.fontFamily), v(t, e);
|
|
56
|
+
const a = new ResizeObserver(() => {
|
|
57
|
+
const c = parseFloat(getComputedStyle(t).fontSize);
|
|
58
|
+
if (isNaN(c)) return;
|
|
59
|
+
const l = p.get(t) ?? -1, u = O(n, c, l, i);
|
|
60
|
+
u !== l && u !== -1 && (g(t, n[u]), p.set(t, u), s == null || s(n[u]));
|
|
61
|
+
});
|
|
62
|
+
a.observe(t);
|
|
63
|
+
const r = () => {
|
|
64
|
+
a.disconnect();
|
|
65
|
+
const c = f.get(t);
|
|
66
|
+
c !== void 0 && (t.style.fontFamily = c, t.style.fontVariationSettings = "", f.delete(t)), p.delete(t), y.delete(t);
|
|
67
|
+
};
|
|
68
|
+
return y.set(t, r), r;
|
|
69
|
+
}
|
|
70
|
+
function R(t) {
|
|
71
|
+
const e = y.get(t);
|
|
72
|
+
if (e) {
|
|
73
|
+
e();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (f.has(t)) {
|
|
77
|
+
const n = f.get(t);
|
|
78
|
+
t.style.fontFamily = n ?? "", t.style.fontVariationSettings = "", f.delete(t), p.delete(t);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function b(t) {
|
|
82
|
+
const e = S(null), n = S(t);
|
|
83
|
+
n.current = t;
|
|
84
|
+
const s = S(null), i = JSON.stringify(t.cuts), { hysteresis: o } = t;
|
|
85
|
+
return z(() => {
|
|
86
|
+
var r;
|
|
87
|
+
const a = e.current;
|
|
88
|
+
if (a)
|
|
89
|
+
return (r = s.current) == null || r.call(s), s.current = V(a, n.current), () => {
|
|
90
|
+
var c;
|
|
91
|
+
(c = s.current) == null || c.call(s), s.current = null;
|
|
92
|
+
};
|
|
93
|
+
}, [i, o]), e;
|
|
94
|
+
}
|
|
95
|
+
const N = h(
|
|
96
|
+
function({ children: e, as: n = "p", cuts: s, hysteresis: i, onCutChange: o, ...a }, r) {
|
|
97
|
+
const u = b({ cuts: s, hysteresis: i, onCutChange: o }), w = M(
|
|
98
|
+
(m) => {
|
|
99
|
+
u.current = m, typeof r == "function" ? r(m) : r && (r.current = m);
|
|
100
|
+
},
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
+
[r]
|
|
103
|
+
);
|
|
104
|
+
return /* @__PURE__ */ F(n, { ref: w, ...a, children: e });
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
N.displayName = "OpszStepperText";
|
|
108
|
+
export {
|
|
109
|
+
N as OpszStepperText,
|
|
110
|
+
v as applyOpszStepper,
|
|
111
|
+
R as removeOpszStepper,
|
|
112
|
+
V as startOpszStepper,
|
|
113
|
+
b as useOpszStepper
|
|
114
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var OpszStepper=(function(u){"use strict";const r=new WeakMap,z=new WeakMap,d=new WeakMap,N=1;function g(t,n){for(let o=0;o<t.length;o++){const s=t[o],i=s.minSize??0,e=s.maxSize??1/0;if(n>=i&&n<e)return o}return-1}function O(t,n,o,s){const i=g(t,n);if(i===-1)return o===-1?i:o;if(o===-1)return i;if(i===o)return o;const e=t[o],a=t[i];if(i>o){const c=(a.minSize??0)+s;return n>c?i:o}else{const c=(e.minSize??0)-s;return n<c?i:o}}function v(t,n){const o=window.scrollY;if(t.style.fontFamily=n.family,n.opszValue!==void 0){const s=n.opszMin??-1/0,i=n.opszMax??1/0,e=Math.min(i,Math.max(s,n.opszValue));t.style.fontVariationSettings=`"opsz" ${e}`}else t.style.fontVariationSettings&&(t.style.fontVariationSettings="");requestAnimationFrame(()=>{Math.abs(window.scrollY-o)>2&&window.scrollTo({top:o,behavior:"instant"})})}function F(t,n){if(typeof window>"u")return;const{cuts:o,onCutChange:s}=n;if(!o||o.length===0)return;const i=parseFloat(getComputedStyle(t).fontSize);if(isNaN(i))return;const e=g(o,i);if(e===-1)return;const a=o[e];r.has(t)||r.set(t,t.style.fontFamily),v(t,a),d.set(t,e),s==null||s(a)}function x(t,n){if(typeof window>"u")return()=>{};const{cuts:o,onCutChange:s}=n,i=n.hysteresis??N;if(!o||o.length===0)return()=>{};const e=z.get(t);e&&e(),r.has(t)||r.set(t,t.style.fontFamily),F(t,n);const a=new ResizeObserver(()=>{const f=parseFloat(getComputedStyle(t).fontSize);if(isNaN(f))return;const y=d.get(t)??-1,p=O(o,f,y,i);p!==y&&p!==-1&&(v(t,o[p]),d.set(t,p),s==null||s(o[p]))});a.observe(t);const c=()=>{a.disconnect();const f=r.get(t);f!==void 0&&(t.style.fontFamily=f,t.style.fontVariationSettings="",r.delete(t)),d.delete(t),z.delete(t)};return z.set(t,c),c}function b(t){const n=z.get(t);if(n){n();return}if(r.has(t)){const o=r.get(t);t.style.fontFamily=o??"",t.style.fontVariationSettings="",r.delete(t),d.delete(t)}}const V="data-opszstepper",S=new WeakMap;function C(t,n,o,s){const i=[];for(const e of t.split(",")){const a=e.trim();if(!a)continue;const[c,f]=a.split(":");if(f===void 0)continue;const y=parseFloat(f.trim());if(isNaN(y))continue;const[p,P]=c.split("-"),h=parseFloat((p??"").trim()),w=parseFloat((P??"").trim()),l={family:n,opszValue:y};isNaN(h)||(l.minSize=h),isNaN(w)||(l.maxSize=w),o!==void 0&&(l.opszMin=o),s!==void 0&&(l.opszMax=s),i.push(l)}return i}function T(t){if(!Array.isArray(t))return[];const n=[];for(const o of t){if(!o||typeof o!="object")continue;const s=o;if(typeof s.family!="string")continue;const i={family:s.family};typeof s.minSize=="number"&&(i.minSize=s.minSize),typeof s.maxSize=="number"&&(i.maxSize=s.maxSize),typeof s.opszValue=="number"&&(i.opszValue=s.opszValue),typeof s.opszMin=="number"&&(i.opszMin=s.opszMin),typeof s.opszMax=="number"&&(i.opszMax=s.opszMax),n.push(i)}return n}function A(t){const n=t.dataset;let o=[];if(n.osCuts)try{o=T(JSON.parse(n.osCuts))}catch{console.error("OpszStepper: data-os-cuts is not valid JSON — ignoring.")}if(o.length===0&&n.osFamily&&n.osOpsz){const i=n.osOpszMin!==void 0?parseFloat(n.osOpszMin):void 0,e=n.osOpszMax!==void 0?parseFloat(n.osOpszMax):void 0;o=C(n.osOpsz,n.osFamily,i!==void 0&&!isNaN(i)?i:void 0,e!==void 0&&!isNaN(e)?e:void 0)}const s={cuts:o};if(n.osHysteresis!==void 0){const i=parseFloat(n.osHysteresis);isNaN(i)||(s.hysteresis=i)}return s}function E(t){M(t);const n=A(t);if(n.cuts.length===0){console.warn("OpszStepper: no valid cuts found on element — skipping.");return}const o=x(t,n);S.set(t,{stop:o})}function M(t){const n=S.get(t);if(!n){b(t);return}n.stop(),S.delete(t)}function m(t=document){t.querySelectorAll(`[${V}]`).forEach(E)}function I(t=document){m(t)}function k(){const t=()=>{var n;(n=document.fonts)!=null&&n.ready?document.fonts.ready.then(()=>m()).catch(()=>m()):m()};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",t,{once:!0}):t()}return k(),u.destroy=M,u.init=m,u.restart=I,Object.defineProperty(u,Symbol.toStringTag,{value:"Module"}),u})({});
|
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@overpunch/opszstepper",
|
|
3
|
+
"version": "1.0.18",
|
|
4
|
+
"description": "Multi-cut optical family hot-swap by font-size — automatically applies Micro, Text, or Display cuts based on the element's computed font-size",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.cjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"require": "./dist/index.cjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "vite build",
|
|
19
|
+
"build:webflow": "vite build --config vite.webflow.config.ts",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:run": "vitest run",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"prepublishOnly": "npm run test && npm run build && npm run build:webflow"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": ">=17",
|
|
27
|
+
"react-dom": ">=17"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"react": {
|
|
31
|
+
"optional": true
|
|
32
|
+
},
|
|
33
|
+
"react-dom": {
|
|
34
|
+
"optional": true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@testing-library/react": "^16.3.2",
|
|
39
|
+
"@testing-library/user-event": "^14.6.1",
|
|
40
|
+
"@types/react": "^19.0.0",
|
|
41
|
+
"@vitejs/plugin-react": "^4.0.0",
|
|
42
|
+
"happy-dom": "^20.10.6",
|
|
43
|
+
"next": "16.2.2",
|
|
44
|
+
"react": "^19.0.0",
|
|
45
|
+
"typescript": "^5.0.0",
|
|
46
|
+
"vite": "^6.0.0",
|
|
47
|
+
"vite-plugin-dts": "^4.0.0",
|
|
48
|
+
"vitest": "^3.0.0"
|
|
49
|
+
},
|
|
50
|
+
"keywords": [
|
|
51
|
+
"typography",
|
|
52
|
+
"web-typography",
|
|
53
|
+
"frontend",
|
|
54
|
+
"liiift-studio",
|
|
55
|
+
"optical-size",
|
|
56
|
+
"font-family",
|
|
57
|
+
"opsz",
|
|
58
|
+
"optical-sizing",
|
|
59
|
+
"font-swap",
|
|
60
|
+
"fluid-type",
|
|
61
|
+
"multi-cut",
|
|
62
|
+
"responsive-type",
|
|
63
|
+
"responsive-design",
|
|
64
|
+
"typographic",
|
|
65
|
+
"zero-dependencies",
|
|
66
|
+
"react",
|
|
67
|
+
"typescript",
|
|
68
|
+
"css"
|
|
69
|
+
],
|
|
70
|
+
"author": "Quinn Keaveney <quinn@liiift.studio>",
|
|
71
|
+
"license": "MIT",
|
|
72
|
+
"homepage": "https://opszstepper.com",
|
|
73
|
+
"repository": {
|
|
74
|
+
"type": "git",
|
|
75
|
+
"url": "git+https://github.com/over-punch/opszStepper.git"
|
|
76
|
+
},
|
|
77
|
+
"bugs": {
|
|
78
|
+
"url": "https://github.com/over-punch/opszStepper/issues"
|
|
79
|
+
},
|
|
80
|
+
"sideEffects": false,
|
|
81
|
+
"publishConfig": {
|
|
82
|
+
"access": "public"
|
|
83
|
+
}
|
|
84
|
+
}
|