@liiift-studio/magnettype 1.0.0

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 ADDED
@@ -0,0 +1,196 @@
1
+ # magnetType
2
+
3
+ CSS `font-variation-settings` applies a single value to the whole element — there is no native way to drive axis values per word from cursor proximity, or to selectively widen visually confusable characters for legibility. magnetType adds both.
4
+
5
+ **[magnettype.com](https://magnettype.com)** · [npm](https://www.npmjs.com/package/@liiift-studio/magnettype) · [GitHub](https://github.com/Liiift-Studio/MagnetType)
6
+
7
+ TypeScript · Zero dependencies · React + Vanilla JS
8
+
9
+ ---
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @liiift-studio/magnettype
15
+ ```
16
+
17
+ ---
18
+
19
+ ## Usage
20
+
21
+ > **Next.js App Router:** this library uses browser APIs. Add `"use client"` to any component file that imports from it.
22
+
23
+ > **Variable font required:** magnetType sets `font-variation-settings` per word or per character. The target font must support the axes you specify (e.g. a font with a `wght` axis for weight-based field effects, or a `wdth` axis for legibility mode). The effect is invisible with fonts that do not have variable axis support.
24
+
25
+ ### React component — field mode
26
+
27
+ ```tsx
28
+ import { MagnetTypeText } from '@liiift-studio/magnettype'
29
+
30
+ <MagnetTypeText
31
+ mode="field"
32
+ axes={{ wght: [300, 700] }}
33
+ radius={150}
34
+ falloff="quadratic"
35
+ magnetMode="attract"
36
+ >
37
+ Your paragraph text here...
38
+ </MagnetTypeText>
39
+ ```
40
+
41
+ ### React hook — field mode
42
+
43
+ ```tsx
44
+ import { useMagnetType } from '@liiift-studio/magnettype'
45
+
46
+ // Inside a React component:
47
+ const ref = useMagnetType({ mode: 'field', axes: { wght: [300, 700] }, radius: 150 })
48
+ return <p ref={ref}>{children}</p>
49
+ ```
50
+
51
+ The hook starts the cursor-proximity rAF loop on mount and tears it down cleanly on unmount. In field mode, no `ResizeObserver` is needed — the loop reads live `getBoundingClientRect` positions on every frame. After fonts load (`document.fonts.ready`), the hook re-runs to ensure measurements are taken on the loaded font.
52
+
53
+ ### React component — legibility mode
54
+
55
+ ```tsx
56
+ import { MagnetTypeText } from '@liiift-studio/magnettype'
57
+
58
+ <MagnetTypeText mode="legibility" wdthBoost={8}>
59
+ Visually confusable characters like il1I and 0O are subtly widened.
60
+ </MagnetTypeText>
61
+ ```
62
+
63
+ ### Vanilla JS — field mode
64
+
65
+ ```ts
66
+ import { startMagnetType, removeMagnetType, getCleanHTML } from '@liiift-studio/magnettype'
67
+
68
+ const el = document.querySelector('p')
69
+ const original = getCleanHTML(el)
70
+ const opts = { mode: 'field', axes: { wght: [300, 700] }, radius: 150 }
71
+
72
+ let stop
73
+
74
+ function run() {
75
+ if (stop) stop()
76
+ stop = startMagnetType(el, original, opts)
77
+ }
78
+
79
+ document.fonts.ready.then(run)
80
+
81
+ // Later — cancel the loop and restore original markup:
82
+ // stop()
83
+ // removeMagnetType(el, original)
84
+ ```
85
+
86
+ ### Vanilla JS — legibility mode
87
+
88
+ ```ts
89
+ import { applyMagnetType, removeMagnetType, getCleanHTML } from '@liiift-studio/magnettype'
90
+
91
+ const el = document.querySelector('p')
92
+ const original = getCleanHTML(el)
93
+ const opts = { mode: 'legibility', wdthBoost: 8 }
94
+
95
+ function run() {
96
+ applyMagnetType(el, original, opts)
97
+ }
98
+
99
+ run()
100
+ document.fonts.ready.then(run)
101
+
102
+ const ro = new ResizeObserver(() => run())
103
+ ro.observe(el)
104
+
105
+ // Later — disconnect and restore original markup:
106
+ // ro.disconnect()
107
+ // removeMagnetType(el, original)
108
+ ```
109
+
110
+ ### TypeScript
111
+
112
+ ```ts
113
+ import type { MagnetTypeOptions, FalloffType, MagnetModeType } from '@liiift-studio/magnettype'
114
+
115
+ const fieldOpts: MagnetTypeOptions = {
116
+ mode: 'field',
117
+ axes: { wght: [300, 700], wdth: [90, 110] },
118
+ radius: 120,
119
+ falloff: 'quadratic' as FalloffType,
120
+ magnetMode: 'attract' as MagnetModeType,
121
+ }
122
+
123
+ const legibilityOpts: MagnetTypeOptions = {
124
+ mode: 'legibility',
125
+ wdthBoost: 6,
126
+ }
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Options
132
+
133
+ | Option | Default | Description |
134
+ |--------|---------|-------------|
135
+ | `mode` | `'field'` | `'field'` — cursor proximity drives per-word `font-variation-settings` via a continuous rAF loop. `'legibility'` — static per-character `wdth` boost applied to visually confusable characters; no cursor interaction needed |
136
+ | `axes` | `{ wght: [300, 500] }` | *(field mode only)* Map of axis tag → `[restValue, peakValue]`. `restValue` is applied when the cursor is beyond the radius; `peakValue` when the cursor is directly over the word. Multiple axes are supported simultaneously |
137
+ | `radius` | `120` | *(field mode only)* Pixel radius over which the field effect fades. Words with their centre beyond this distance from the cursor receive `restValue` |
138
+ | `falloff` | `'quadratic'` | *(field mode only)* Falloff curve. `'linear'` — strength decreases linearly with distance. `'quadratic'` — strength decreases as distance², giving a tighter hot zone and a sharper peak feel |
139
+ | `magnetMode` | `'attract'` | *(field mode only)* `'attract'` — words near the cursor approach `peakValue`. `'repel'` — words near the cursor stay at `restValue`; words farther away approach `peakValue` |
140
+ | `wdthBoost` | `6` | *(legibility mode only)* `wdth` axis units added to confusable characters, scaled by risk level. Risk 3 characters (`i l 1 I`) receive the full boost; risk 2 characters (`r 0 O`) receive ⅔; risk 1 characters (`n m o b d p q c e`) receive ⅓ |
141
+ | `as` | `'p'` | HTML element to render, e.g. `'h1'`, `'div'`, `'span'`. Accepts any valid React element type. *(React component only)* |
142
+
143
+ ---
144
+
145
+ ## How it works
146
+
147
+ ### Field mode
148
+
149
+ On activation, magnetType wraps each word in the element in an `mt-word` span. A `mousemove` listener records the cursor's `clientX`/`clientY` coordinates, and a `requestAnimationFrame` loop runs continuously while the cursor is inside the element. Each frame, the loop batch-reads every word span's `getBoundingClientRect`, computes the Euclidean distance from the cursor to each word's centre, and maps that distance through the falloff formula to a normalised strength value in `[0, 1]`:
150
+
151
+ ```
152
+ normalised = max(0, 1 − distance / radius)
153
+ strength = normalised² (quadratic) or normalised (linear)
154
+ ```
155
+
156
+ Each word's `font-variation-settings` is then set to the interpolated axis value between `restValue` and `peakValue`, with `attract` mode mapping `strength=1` to `peakValue` and `repel` mode inverting that relationship. Reads are batched before writes on every frame to avoid layout thrashing. When the cursor leaves the element, the loop fires one final frame to reset all words to `restValue`, then idles.
157
+
158
+ The base `fontVariationSettings` string is read from the computed style of the element once at startup, and each per-word override patches only the affected axes — all parent-defined axes are preserved.
159
+
160
+ ### Legibility mode
161
+
162
+ magnetType scans all text nodes in the element using recursive `childNodes` traversal and checks each character against a built-in confusable character table. Confusable characters are grouped into three risk levels: `il1I` (risk 3, high confusion), `r 0 O` and related pairs (risk 2), and `n m o b d p q c e` (risk 1, low confusion). Each confusable character is wrapped in an `mt-char` span with a `wdth` axis boost proportional to its risk level — making similar-looking characters slightly wider and more distinct. Non-confusable characters pass through as plain text nodes, with adjacent non-confusable characters consolidated into single text nodes to keep the DOM lean.
163
+
164
+ ### No layout shift
165
+
166
+ In field mode, the rAF loop drives only `font-variation-settings` values on per-word spans — it does not change element widths, margins, padding, or position. If you use only a `wght` axis, advance widths are not affected and no reflow occurs. If you include a `wdth` axis, character advance widths will change, which may cause lines to reflow. To prevent this, consider constraining axis ranges or combining with a `scaleX` transform on the container.
167
+
168
+ In legibility mode, the `wdth` axis boost widens individual confusable characters, which shifts surrounding characters slightly. This is intentional — the point is to make the characters physically wider and more distinct. The shift is small by default (`wdthBoost: 6`) and does not cause line breaks to change.
169
+
170
+ ### `prefers-reduced-motion`
171
+
172
+ Field mode respects `prefers-reduced-motion: reduce`. If the media query matches at the time `startMagnetType` is called, the function returns immediately without wrapping words or starting the rAF loop, and returns a no-op stop function. Legibility mode is a static DOM transformation and is not affected by this preference.
173
+
174
+ ---
175
+
176
+ ## Dev notes
177
+
178
+ ### `next` in root devDependencies
179
+
180
+ `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.
181
+
182
+ The package itself has zero runtime dependencies. Do not remove this entry.
183
+
184
+ ---
185
+
186
+ ## Future improvements
187
+
188
+ - **Touch support** — `touchmove` events for mobile field mode; currently `mousemove`-only, so the effect is desktop-exclusive
189
+ - **Smooth transition on stop** — animate axis values back to `restValue` over a short duration when the cursor leaves, rather than snapping on the next rAF tick
190
+ - **Custom confusable table** — allow callers to pass their own `Record<string, number>` to override or extend the built-in character risk map for language- or font-specific tuning
191
+ - **Axis clamping** — optional per-axis min/max clamp to prevent axis values from exceeding a font's supported range, avoiding undefined browser rendering behaviour
192
+ - **SSR hydration** — pre-render legibility mode markup on the server so boosted characters are present from first paint without a client-side flash
193
+
194
+ ---
195
+
196
+ Current version: v0.0.1
package/dist/index.cjs ADDED
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const x=require("react"),I=require("react/jsx-runtime"),j={i:3,l:3,1:3,I:3,r:2,n:1,m:1,0:2,O:2,o:1,b:1,d:1,p:1,q:1,c:1,e:1},S={word:"mt-word",char:"mt-char",probe:"mt-probe"},N={axes:{wght:[300,500]},radius:120,falloff:"quadratic",magnetMode:"attract",wdthBoost:6};function F(e,n=[]){return e.nodeType===Node.TEXT_NODE?n.push(e):e.childNodes.forEach(o=>F(o,n)),n}function H(e,n,o){if(!e||e==="normal")return`"${n}" ${o}`;const r=new RegExp(`(["'])${n}\\1\\s+[\\d.eE+-]+`),t=`"${n}" ${o}`;return r.test(e)?e.replace(r,t):`${e}, ${t}`}function B(e,n){let o=e;for(const[r,t]of Object.entries(n))o=H(o,r,t);return o}function V(e){const n=e.cloneNode(!0),o=n.querySelectorAll(`.${S.word}, .${S.char}`);return Array.from(o).reverse().forEach(t=>{const l=t.parentNode;if(l){for(;t.firstChild;)l.insertBefore(t.firstChild,t);l.removeChild(t)}}),n.innerHTML}function X(e,n){e.innerHTML=n}function k(e,n,o={}){if(typeof window>"u")return;const r=window.scrollY,t=o.wdthBoost??N.wdthBoost;e.innerHTML=n;const l=F(e),a=getComputedStyle(e).fontVariationSettings;for(const h of l){const y=h.textContent??"";if(!y||!y.split("").some(m=>m in j))continue;const M=document.createDocumentFragment();for(const m of y){const p=j[m];if(p===void 0){const s=M.lastChild;s&&s.nodeType===Node.TEXT_NODE?s.textContent+=m:M.appendChild(document.createTextNode(m))}else{const s=t*(p/3),c=document.createElement("span");c.className=S.char;const f=a.match(/"wdth"\s+([\d.eE+-]+)/),E=(f?parseFloat(f[1]):100)+s;c.style.fontVariationSettings=H(a,"wdth",E),c.textContent=m,M.appendChild(c)}}h.parentNode.replaceChild(M,h)}requestAnimationFrame(()=>{typeof window<"u"&&Math.abs(window.scrollY-r)>2&&window.scrollTo({top:r,behavior:"instant"})})}function W(e,n,o={}){if(typeof window>"u")return()=>{};if(window.matchMedia("(prefers-reduced-motion: reduce)").matches)return e.innerHTML=n,()=>{};const r=o.axes??N.axes,t=o.radius??N.radius,l=o.falloff??N.falloff,a=o.magnetMode??N.magnetMode,h=window.scrollY;e.innerHTML=n;const y=F(e),u=[];for(const i of y){const d=i.textContent??"";if(!d.trim())continue;const w=d.split(/(\S+)/),T=document.createDocumentFragment();for(let g=0;g<w.length;g+=2){const R=w[g],b=w[g+1];if(!b)continue;const q=w[g+3]===void 0?w[g+2]??"":"",v=document.createElement("span");v.className=S.word,v.textContent=R+b+q,T.appendChild(v),u.push(v)}i.parentNode.replaceChild(T,i)}if(requestAnimationFrame(()=>{typeof window<"u"&&Math.abs(window.scrollY-h)>2&&window.scrollTo({top:h,behavior:"instant"})}),u.length===0)return()=>{};const M=getComputedStyle(e).fontVariationSettings,m=B(M,Object.fromEntries(Object.entries(r).map(([i,[d]])=>[i,d])));u.forEach(i=>{i.style.fontVariationSettings=m});let p=-9999,s=-9999,c=!1,f=0,C=!0;function E(i,d){const[w,T]=r[i]??[300,500],g=a==="repel"?1-d:d;return w+(T-w)*g}function L(){if(!C)return;if(!c){u.forEach(d=>{d.style.fontVariationSettings=m}),f=0;return}const i=u.map(d=>d.getBoundingClientRect());u.forEach((d,w)=>{const T=i[w],g=T.left+T.width/2,R=T.top+T.height/2,b=Math.sqrt((p-g)**2+(s-R)**2),A=Math.max(0,1-b/t),q=l==="quadratic"?A*A:A,v={};for(const $ of Object.keys(r))v[$]=E($,q);d.style.fontVariationSettings=B(M,v)}),f=requestAnimationFrame(L)}function O(i){p=i.clientX,s=i.clientY,c||(c=!0),f===0&&(f=requestAnimationFrame(L))}function Y(){c=!1,f===0&&(f=requestAnimationFrame(L))}return e.addEventListener("mousemove",O),e.addEventListener("mouseleave",Y),()=>{C=!1,cancelAnimationFrame(f),e.removeEventListener("mousemove",O),e.removeEventListener("mouseleave",Y),e.innerHTML=n}}function _(e){const n=x.useRef(null),o=x.useRef(null),r=x.useRef(e);r.current=e;const t=x.useRef(null),l=e.mode??"field",{axes:a,radius:h,falloff:y,magnetMode:u,wdthBoost:M}=e,m=a?JSON.stringify(a):void 0,p=x.useCallback(()=>{const s=n.current;if(!s)return;o.current===null&&(o.current=V(s)),t.current&&(t.current(),t.current=null),(r.current.mode??"field")==="field"?t.current=W(s,o.current,r.current):k(s,o.current,r.current)},[l,m,h,y,u,M]);return x.useLayoutEffect(()=>{if(p(),typeof ResizeObserver>"u")return()=>{t.current&&(t.current(),t.current=null)};if((r.current.mode??"field")==="field")return()=>{t.current&&(t.current(),t.current=null)};let s=0,c=0;const f=new ResizeObserver(C=>{const E=Math.round(C[0].contentRect.width);E!==s&&(s=E,cancelAnimationFrame(c),c=requestAnimationFrame(p))});return f.observe(n.current),()=>{f.disconnect(),cancelAnimationFrame(c),t.current&&(t.current(),t.current=null)}},[p]),x.useEffect(()=>{document.fonts.ready.then(p)},[p]),n}const D=x.forwardRef(function({children:n,as:o="p",className:r,style:t,...l},a){const h=_(l),y=x.useCallback(u=>{h.current=u,typeof a=="function"?a(u):a&&(a.current=u)},[a]);return I.jsx(o,{ref:y,className:r,style:t,children:n})});D.displayName="MagnetTypeText";exports.MAGNET_TYPE_CLASSES=S;exports.MagnetTypeText=D;exports.applyMagnetType=k;exports.getCleanHTML=V;exports.removeMagnetType=X;exports.startMagnetType=W;exports.useMagnetType=_;
@@ -0,0 +1,142 @@
1
+ import { default as default_2 } from 'react';
2
+ import { RefObject } from 'react';
3
+
4
+ /**
5
+ * Apply per-character wdth boost to visually confusable characters (legibility mode).
6
+ *
7
+ * Wraps each character in a span with a boosted wdth axis value proportional to
8
+ * the character's confusion risk level. Non-confusable characters are left unwrapped.
9
+ *
10
+ * @param element - Target element (must be in the live DOM and visible)
11
+ * @param originalHTML - Clean HTML snapshot from getCleanHTML()
12
+ * @param options - MagnetTypeOptions; only wdthBoost is used here
13
+ */
14
+ export declare function applyMagnetType(element: HTMLElement, originalHTML: string, options?: MagnetTypeOptions): void;
15
+
16
+ /** Falloff curve for the cursor proximity field */
17
+ export declare type FalloffType = 'linear' | 'quadratic';
18
+
19
+ /**
20
+ * Returns the innerHTML of an element with all magnetType injected markup removed,
21
+ * unwrapping their children in place. Safe to call multiple times — idempotent.
22
+ */
23
+ export declare function getCleanHTML(el: HTMLElement): string;
24
+
25
+ /** CSS class names injected by magnetType — use these to target generated markup */
26
+ export declare const MAGNET_TYPE_CLASSES: {
27
+ /** Applied to each word span in field mode */
28
+ readonly word: "mt-word";
29
+ /** Applied to each character span in legibility mode */
30
+ readonly char: "mt-char";
31
+ /** Applied to measurement probe spans (never in final output) */
32
+ readonly probe: "mt-probe";
33
+ };
34
+
35
+ /** Whether cursor proximity attracts toward peak or repels toward rest */
36
+ export declare type MagnetModeType = 'attract' | 'repel';
37
+
38
+ /** Which mode magnetType operates in */
39
+ export declare type MagnetTypeModeType = 'field' | 'legibility';
40
+
41
+ /**
42
+ * Options for the magnetType effect.
43
+ *
44
+ * Two modes share this options type:
45
+ * - 'field' — cursor proximity drives per-word variable font axis values
46
+ * - 'legibility' — per-character wdth boost for visually confusable characters
47
+ */
48
+ export declare interface MagnetTypeOptions {
49
+ /**
50
+ * Operating mode. Default: 'field'
51
+ *
52
+ * - **'field'** — cursor proximity drives per-word font-variation-settings.
53
+ * - **'legibility'** — per-character wdth boost for confusable characters; no cursor needed.
54
+ */
55
+ mode?: MagnetTypeModeType;
56
+ /**
57
+ * Map of axis tag → [restValue, peakValue].
58
+ * restValue is applied at full distance; peakValue when cursor is directly over the word.
59
+ * Default: { wght: [300, 500] }
60
+ */
61
+ axes?: Record<string, [number, number]>;
62
+ /**
63
+ * Pixel radius over which the field effect fades.
64
+ * Words beyond this distance receive restValue. Default: 120
65
+ */
66
+ radius?: number;
67
+ /**
68
+ * Falloff curve. Default: 'quadratic'
69
+ *
70
+ * - **'linear'** — strength decreases linearly with distance
71
+ * - **'quadratic'** — strength decreases as distance², giving a tighter hot zone
72
+ */
73
+ falloff?: FalloffType;
74
+ /**
75
+ * Attraction or repulsion mode. Default: 'attract'
76
+ *
77
+ * - **'attract'** — words near cursor approach peakValue
78
+ * - **'repel'** — words near cursor stay at restValue; far words approach peakValue
79
+ */
80
+ magnetMode?: MagnetModeType;
81
+ /**
82
+ * wdth axis units to add to confusable characters. Default: 6
83
+ *
84
+ * Risk 1 characters receive wdthBoost × (1/3).
85
+ * Risk 2 characters receive wdthBoost × (2/3).
86
+ * Risk 3 characters receive wdthBoost × (3/3) = full boost.
87
+ */
88
+ wdthBoost?: number;
89
+ }
90
+
91
+ /**
92
+ * Drop-in component that applies the magnetType effect to its children.
93
+ * Forwards the ref to the root element while also attaching the internal hook ref.
94
+ */
95
+ export declare const MagnetTypeText: default_2.ForwardRefExoticComponent<MagnetTypeTextProps & default_2.RefAttributes<HTMLElement>>;
96
+
97
+ /** Props for MagnetTypeText — extends all MagnetTypeOptions */
98
+ declare interface MagnetTypeTextProps extends MagnetTypeOptions {
99
+ children: default_2.ReactNode;
100
+ /** HTML element to render. Default: 'p' */
101
+ as?: default_2.ElementType;
102
+ className?: string;
103
+ style?: default_2.CSSProperties;
104
+ }
105
+
106
+ /**
107
+ * Remove magnetType markup and restore the element to its original HTML.
108
+ *
109
+ * @param element - The element that was previously modified
110
+ * @param originalHTML - The snapshot passed to applyMagnetType
111
+ */
112
+ export declare function removeMagnetType(element: HTMLElement, originalHTML: string): void;
113
+
114
+ /**
115
+ * Start the cursor-field effect on an element.
116
+ *
117
+ * Wraps each word in a span, then listens for mousemove events to drive per-word
118
+ * font-variation-settings based on cursor distance. Uses a requestAnimationFrame
119
+ * loop for smooth axis interpolation. Resets all words to restValue on mouseleave.
120
+ *
121
+ * @param element - Target element (must be in the live DOM and visible)
122
+ * @param originalHTML - Clean HTML snapshot from getCleanHTML()
123
+ * @param options - MagnetTypeOptions; axes, radius, falloff, magnetMode are used
124
+ * @returns - A stop function. Call it to cancel the rAF loop and restore markup.
125
+ */
126
+ export declare function startMagnetType(element: HTMLElement, originalHTML: string, options?: MagnetTypeOptions): () => void;
127
+
128
+ /**
129
+ * React hook that applies the magnetType effect to a ref'd element.
130
+ *
131
+ * For mode: 'field' — starts the cursor proximity rAF loop via startMagnetType.
132
+ * The stop function is called on unmount and when options change.
133
+ * No ResizeObserver needed — the rAF loop reads live getBoundingClientRect each frame.
134
+ *
135
+ * For mode: 'legibility' — applies the static per-character wdth boost via applyMagnetType.
136
+ * Re-runs on container width changes via ResizeObserver, and after fonts load.
137
+ *
138
+ * Defaults to 'field' mode if mode is undefined.
139
+ */
140
+ export declare function useMagnetType(options: MagnetTypeOptions): RefObject<HTMLElement | null>;
141
+
142
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,208 @@
1
+ import { useRef as A, useCallback as j, useLayoutEffect as W, useEffect as k, forwardRef as D } from "react";
2
+ import { jsx as I } from "react/jsx-runtime";
3
+ const B = {
4
+ i: 3,
5
+ l: 3,
6
+ 1: 3,
7
+ I: 3,
8
+ // il1I confusion
9
+ r: 2,
10
+ n: 1,
11
+ m: 1,
12
+ // rn/m similarity
13
+ 0: 2,
14
+ O: 2,
15
+ o: 1,
16
+ // 0/O similarity
17
+ b: 1,
18
+ d: 1,
19
+ p: 1,
20
+ q: 1,
21
+ // mirrored pairs
22
+ c: 1,
23
+ e: 1
24
+ // similar bowls
25
+ }, L = {
26
+ /** Applied to each word span in field mode */
27
+ word: "mt-word",
28
+ /** Applied to each character span in legibility mode */
29
+ char: "mt-char",
30
+ /** Applied to measurement probe spans (never in final output) */
31
+ probe: "mt-probe"
32
+ }, C = {
33
+ axes: { wght: [300, 500] },
34
+ radius: 120,
35
+ falloff: "quadratic",
36
+ magnetMode: "attract",
37
+ wdthBoost: 6
38
+ };
39
+ function q(e, n = []) {
40
+ return e.nodeType === Node.TEXT_NODE ? n.push(e) : e.childNodes.forEach((o) => q(o, n)), n;
41
+ }
42
+ function H(e, n, o) {
43
+ if (!e || e === "normal") return `"${n}" ${o}`;
44
+ const r = new RegExp(`(["'])${n}\\1\\s+[\\d.eE+-]+`), t = `"${n}" ${o}`;
45
+ return r.test(e) ? e.replace(r, t) : `${e}, ${t}`;
46
+ }
47
+ function V(e, n) {
48
+ let o = e;
49
+ for (const [r, t] of Object.entries(n))
50
+ o = H(o, r, t);
51
+ return o;
52
+ }
53
+ function X(e) {
54
+ const n = e.cloneNode(!0), o = n.querySelectorAll(
55
+ `.${L.word}, .${L.char}`
56
+ );
57
+ return Array.from(o).reverse().forEach((t) => {
58
+ const l = t.parentNode;
59
+ if (l) {
60
+ for (; t.firstChild; ) l.insertBefore(t.firstChild, t);
61
+ l.removeChild(t);
62
+ }
63
+ }), n.innerHTML;
64
+ }
65
+ function P(e, n) {
66
+ e.innerHTML = n;
67
+ }
68
+ function _(e, n, o = {}) {
69
+ if (typeof window > "u") return;
70
+ const r = window.scrollY, t = o.wdthBoost ?? C.wdthBoost;
71
+ e.innerHTML = n;
72
+ const l = q(e), a = getComputedStyle(e).fontVariationSettings;
73
+ for (const h of l) {
74
+ const y = h.textContent ?? "";
75
+ if (!y || !y.split("").some((m) => m in B)) continue;
76
+ const M = document.createDocumentFragment();
77
+ for (const m of y) {
78
+ const p = B[m];
79
+ if (p === void 0) {
80
+ const s = M.lastChild;
81
+ s && s.nodeType === Node.TEXT_NODE ? s.textContent += m : M.appendChild(document.createTextNode(m));
82
+ } else {
83
+ const s = t * (p / 3), c = document.createElement("span");
84
+ c.className = L.char;
85
+ const u = a.match(/"wdth"\s+([\d.eE+-]+)/), v = (u ? parseFloat(u[1]) : 100) + s;
86
+ c.style.fontVariationSettings = H(a, "wdth", v), c.textContent = m, M.appendChild(c);
87
+ }
88
+ }
89
+ h.parentNode.replaceChild(M, h);
90
+ }
91
+ requestAnimationFrame(() => {
92
+ typeof window < "u" && Math.abs(window.scrollY - r) > 2 && window.scrollTo({ top: r, behavior: "instant" });
93
+ });
94
+ }
95
+ function z(e, n, o = {}) {
96
+ if (typeof window > "u") return () => {
97
+ };
98
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
99
+ return e.innerHTML = n, () => {
100
+ };
101
+ const r = o.axes ?? C.axes, t = o.radius ?? C.radius, l = o.falloff ?? C.falloff, a = o.magnetMode ?? C.magnetMode, h = window.scrollY;
102
+ e.innerHTML = n;
103
+ const y = q(e), f = [];
104
+ for (const i of y) {
105
+ const d = i.textContent ?? "";
106
+ if (!d.trim()) continue;
107
+ const w = d.split(/(\S+)/), x = document.createDocumentFragment();
108
+ for (let g = 0; g < w.length; g += 2) {
109
+ const F = w[g], N = w[g + 1];
110
+ if (!N) continue;
111
+ const R = w[g + 3] === void 0 ? w[g + 2] ?? "" : "", T = document.createElement("span");
112
+ T.className = L.word, T.textContent = F + N + R, x.appendChild(T), f.push(T);
113
+ }
114
+ i.parentNode.replaceChild(x, i);
115
+ }
116
+ if (requestAnimationFrame(() => {
117
+ typeof window < "u" && Math.abs(window.scrollY - h) > 2 && window.scrollTo({ top: h, behavior: "instant" });
118
+ }), f.length === 0) return () => {
119
+ };
120
+ const M = getComputedStyle(e).fontVariationSettings, m = V(
121
+ M,
122
+ Object.fromEntries(Object.entries(r).map(([i, [d]]) => [i, d]))
123
+ );
124
+ f.forEach((i) => {
125
+ i.style.fontVariationSettings = m;
126
+ });
127
+ let p = -9999, s = -9999, c = !1, u = 0, E = !0;
128
+ function v(i, d) {
129
+ const [w, x] = r[i] ?? [300, 500], g = a === "repel" ? 1 - d : d;
130
+ return w + (x - w) * g;
131
+ }
132
+ function S() {
133
+ if (!E) return;
134
+ if (!c) {
135
+ f.forEach((d) => {
136
+ d.style.fontVariationSettings = m;
137
+ }), u = 0;
138
+ return;
139
+ }
140
+ const i = f.map((d) => d.getBoundingClientRect());
141
+ f.forEach((d, w) => {
142
+ const x = i[w], g = x.left + x.width / 2, F = x.top + x.height / 2, N = Math.sqrt((p - g) ** 2 + (s - F) ** 2), b = Math.max(0, 1 - N / t), R = l === "quadratic" ? b * b : b, T = {};
143
+ for (const $ of Object.keys(r))
144
+ T[$] = v($, R);
145
+ d.style.fontVariationSettings = V(M, T);
146
+ }), u = requestAnimationFrame(S);
147
+ }
148
+ function O(i) {
149
+ p = i.clientX, s = i.clientY, c || (c = !0), u === 0 && (u = requestAnimationFrame(S));
150
+ }
151
+ function Y() {
152
+ c = !1, u === 0 && (u = requestAnimationFrame(S));
153
+ }
154
+ return e.addEventListener("mousemove", O), e.addEventListener("mouseleave", Y), () => {
155
+ E = !1, cancelAnimationFrame(u), e.removeEventListener("mousemove", O), e.removeEventListener("mouseleave", Y), e.innerHTML = n;
156
+ };
157
+ }
158
+ function U(e) {
159
+ const n = A(null), o = A(null), r = A(e);
160
+ r.current = e;
161
+ const t = A(null), l = e.mode ?? "field", { axes: a, radius: h, falloff: y, magnetMode: f, wdthBoost: M } = e, m = a ? JSON.stringify(a) : void 0, p = j(() => {
162
+ const s = n.current;
163
+ if (!s) return;
164
+ o.current === null && (o.current = X(s)), t.current && (t.current(), t.current = null), (r.current.mode ?? "field") === "field" ? t.current = z(s, o.current, r.current) : _(s, o.current, r.current);
165
+ }, [l, m, h, y, f, M]);
166
+ return W(() => {
167
+ if (p(), typeof ResizeObserver > "u")
168
+ return () => {
169
+ t.current && (t.current(), t.current = null);
170
+ };
171
+ if ((r.current.mode ?? "field") === "field")
172
+ return () => {
173
+ t.current && (t.current(), t.current = null);
174
+ };
175
+ let s = 0, c = 0;
176
+ const u = new ResizeObserver((E) => {
177
+ const v = Math.round(E[0].contentRect.width);
178
+ v !== s && (s = v, cancelAnimationFrame(c), c = requestAnimationFrame(p));
179
+ });
180
+ return u.observe(n.current), () => {
181
+ u.disconnect(), cancelAnimationFrame(c), t.current && (t.current(), t.current = null);
182
+ };
183
+ }, [p]), k(() => {
184
+ document.fonts.ready.then(p);
185
+ }, [p]), n;
186
+ }
187
+ const G = D(
188
+ function({ children: n, as: o = "p", className: r, style: t, ...l }, a) {
189
+ const h = U(l), y = j(
190
+ (f) => {
191
+ h.current = f, typeof a == "function" ? a(f) : a && (a.current = f);
192
+ },
193
+ // eslint-disable-next-line react-hooks/exhaustive-deps
194
+ [a]
195
+ );
196
+ return /* @__PURE__ */ I(o, { ref: y, className: r, style: t, children: n });
197
+ }
198
+ );
199
+ G.displayName = "MagnetTypeText";
200
+ export {
201
+ L as MAGNET_TYPE_CLASSES,
202
+ G as MagnetTypeText,
203
+ _ as applyMagnetType,
204
+ X as getCleanHTML,
205
+ P as removeMagnetType,
206
+ z as startMagnetType,
207
+ U as useMagnetType
208
+ };
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@liiift-studio/magnettype",
3
+ "version": "1.0.0",
4
+ "description": "Cursor-field per-word variable font axis variation and per-character legibility mode",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "vite build",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest",
23
+ "lint": "tsc --noEmit",
24
+ "prepublishOnly": "npm run test && npm run build"
25
+ },
26
+ "peerDependencies": {
27
+ "react": ">=17",
28
+ "react-dom": ">=17"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "react": {
32
+ "optional": true
33
+ },
34
+ "react-dom": {
35
+ "optional": true
36
+ }
37
+ },
38
+ "devDependencies": {
39
+ "@types/react": "^19.0.0",
40
+ "@vitejs/plugin-react": "^4.0.0",
41
+ "happy-dom": "^12.0.0",
42
+ "next": "16.2.2",
43
+ "react": "^19.0.0",
44
+ "typescript": "^5.0.0",
45
+ "vite": "^6.0.0",
46
+ "vite-plugin-dts": "^4.0.0",
47
+ "vitest": "^3.0.0"
48
+ },
49
+ "keywords": [
50
+ "typography",
51
+ "typographic",
52
+ "variable-font",
53
+ "font-variation-settings",
54
+ "cursor",
55
+ "mouse",
56
+ "proximity",
57
+ "field",
58
+ "legibility",
59
+ "wdth",
60
+ "wght",
61
+ "zero-dependencies",
62
+ "react",
63
+ "typescript",
64
+ "css"
65
+ ],
66
+ "author": "Quinn Keaveney <quinn@liiift.studio>",
67
+ "license": "MIT",
68
+ "homepage": "https://magnettype.com",
69
+ "repository": {
70
+ "type": "git",
71
+ "url": "https://github.com/Liiift-Studio/MagnetType.git"
72
+ },
73
+ "bugs": {
74
+ "url": "https://github.com/Liiift-Studio/MagnetType/issues"
75
+ },
76
+ "sideEffects": false,
77
+ "publishConfig": {
78
+ "access": "public"
79
+ }
80
+ }