@3xhaust/oh-my-design 0.16.1

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.
Files changed (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +282 -0
  3. package/README.md +282 -0
  4. package/adapters/build.ts +184 -0
  5. package/adapters/claude.ts +111 -0
  6. package/adapters/codex.ts +68 -0
  7. package/adapters/tokens.ts +21 -0
  8. package/adapters/tool-map.json +7 -0
  9. package/bin/omd-install.ts +76 -0
  10. package/bin/omd.ts +1774 -0
  11. package/core/asset-sourcing/index.ts +187 -0
  12. package/core/coach/index.ts +98 -0
  13. package/core/composition/app-shell-workbench.md +168 -0
  14. package/core/composition/asymmetric-diagonal-grid.md +178 -0
  15. package/core/composition/bento-grid.md +223 -0
  16. package/core/composition/editorial-index-labels.md +173 -0
  17. package/core/composition/form-wizard-stepper.md +132 -0
  18. package/core/composition/master-detail-flow.md +129 -0
  19. package/core/composition/section-inversion.md +182 -0
  20. package/core/composition/sidebar-margin-annotation.md +184 -0
  21. package/core/composition/split-screen-hero.md +224 -0
  22. package/core/composition/sticky-sidebar-scroll.md +219 -0
  23. package/core/composition/typographic-hero.md +100 -0
  24. package/core/composition-contract/index.ts +233 -0
  25. package/core/composition-contract/visual-richness.ts +101 -0
  26. package/core/config/index.ts +27 -0
  27. package/core/copy/index.ts +296 -0
  28. package/core/craft/finish-pass.md +356 -0
  29. package/core/craft/index.ts +33 -0
  30. package/core/design/index.ts +581 -0
  31. package/core/design/interaction-states.ts +189 -0
  32. package/core/eval-harness/index.ts +225 -0
  33. package/core/evidence/final.ts +444 -0
  34. package/core/evidence/task.ts +459 -0
  35. package/core/figma/client.ts +312 -0
  36. package/core/figma/diff.ts +261 -0
  37. package/core/figma/responsive.ts +310 -0
  38. package/core/figma/system.ts +466 -0
  39. package/core/figma/types.ts +185 -0
  40. package/core/frame/check-ux.ts +180 -0
  41. package/core/frame/index.ts +39 -0
  42. package/core/frame/write.ts +168 -0
  43. package/core/graphics/css-illustration-primitives.md +189 -0
  44. package/core/graphics/duotone-image-presets.md +173 -0
  45. package/core/graphics/gradient-mesh.md +178 -0
  46. package/core/graphics/noise-grain-texture.md +150 -0
  47. package/core/graphics/placeholder-policy.md +185 -0
  48. package/core/graphics/svg-geometric-patterns.md +145 -0
  49. package/core/history/index.ts +39 -0
  50. package/core/install/detect.ts +32 -0
  51. package/core/install/install.ts +321 -0
  52. package/core/install/patch-claude.ts +109 -0
  53. package/core/install/patch-codex.ts +82 -0
  54. package/core/interaction/index.ts +101 -0
  55. package/core/interaction/recipes/signature-lighting.md +203 -0
  56. package/core/ir/dom.ts +352 -0
  57. package/core/ir/normalize.ts +177 -0
  58. package/core/motion/easing.md +151 -0
  59. package/core/motion/energy.ts +188 -0
  60. package/core/motion/recipes/image-hover-distortion.md +198 -0
  61. package/core/motion/recipes/magnetic-hover.md +229 -0
  62. package/core/motion/recipes/marquee.md +202 -0
  63. package/core/motion/recipes/number-counter.md +242 -0
  64. package/core/motion/recipes/page-loader.md +240 -0
  65. package/core/motion/recipes/parallax.md +233 -0
  66. package/core/motion/recipes/scroll-reveal.md +210 -0
  67. package/core/motion/recipes/section-color-inversion.md +217 -0
  68. package/core/motion/recipes/split-text-entrance.md +222 -0
  69. package/core/motion/recipes/stagger-orchestrator.md +227 -0
  70. package/core/motion/recipes/sticky-scene-transition.md +246 -0
  71. package/core/motion/recipes/view-transitions.md +239 -0
  72. package/core/probe/index.ts +186 -0
  73. package/core/probe/schema.json +46 -0
  74. package/core/protocol/composition-contract.md +192 -0
  75. package/core/protocol/copy-deck.md +96 -0
  76. package/core/protocol/human-design-loop.md +447 -0
  77. package/core/protocol/slop-review.md +71 -0
  78. package/core/ref/batch.ts +94 -0
  79. package/core/ref/blueprint.ts +199 -0
  80. package/core/ref/distance.ts +225 -0
  81. package/core/ref/invariants.ts +162 -0
  82. package/core/ref/signal.ts +37 -0
  83. package/core/ref/store.ts +122 -0
  84. package/core/render/index.ts +607 -0
  85. package/core/rules/attribution.ts +159 -0
  86. package/core/rules/builtin/contrast.yaml +8 -0
  87. package/core/rules/builtin/focus.yaml +8 -0
  88. package/core/rules/builtin/hit-area.yaml +8 -0
  89. package/core/rules/builtin/ko.yaml +6 -0
  90. package/core/rules/builtin/motion.yaml +80 -0
  91. package/core/rules/builtin/slop.yaml +365 -0
  92. package/core/rules/builtin/spacing.yaml +9 -0
  93. package/core/rules/builtin/system.yaml +42 -0
  94. package/core/rules/builtin/token.yaml +8 -0
  95. package/core/rules/builtin/ux.yaml +125 -0
  96. package/core/rules/engine.ts +88 -0
  97. package/core/rules/leakage.ts +72 -0
  98. package/core/rules/motion-spec.ts +204 -0
  99. package/core/site/index.ts +100 -0
  100. package/core/slop/index.ts +427 -0
  101. package/core/slop/text-slop.ts +220 -0
  102. package/core/source-seal/index.ts +174 -0
  103. package/core/stack/index.ts +53 -0
  104. package/core/target/index.ts +183 -0
  105. package/core/theory/color.md +285 -0
  106. package/core/theory/components.md +272 -0
  107. package/core/theory/craft.md +270 -0
  108. package/core/theory/expressive.md +213 -0
  109. package/core/theory/imagegen.md +105 -0
  110. package/core/theory/layout.md +159 -0
  111. package/core/theory/motion.md +283 -0
  112. package/core/theory/typography.md +134 -0
  113. package/core/theory/ux.md +580 -0
  114. package/core/theory/voice.md +595 -0
  115. package/core/types.ts +684 -0
  116. package/package.json +40 -0
  117. package/src/agents/composer.agent.yaml +174 -0
  118. package/src/agents/eye.agent.yaml +208 -0
  119. package/src/agents/framer.agent.yaml +52 -0
  120. package/src/agents/glance.agent.yaml +19 -0
  121. package/src/agents/hand.agent.yaml +200 -0
  122. package/src/agents/scout.agent.yaml +71 -0
  123. package/src/agents/sketch.agent.yaml +64 -0
  124. package/src/agents/typesetter.agent.yaml +52 -0
  125. package/src/agents/writer.agent.yaml +63 -0
  126. package/src/skills/omd-coach/SKILL.md +40 -0
  127. package/src/skills/omd-critique/SKILL.md +77 -0
  128. package/src/skills/omd-figma/SKILL.md +256 -0
  129. package/src/skills/omd-humanize/SKILL.md +160 -0
  130. package/src/skills/omd-scout/SKILL.md +77 -0
  131. package/src/skills/omd-ultradesign/SKILL.md +521 -0
@@ -0,0 +1,188 @@
1
+ import { inflateSync } from 'node:zlib';
2
+ import type { EnergyCurve, FrameEnergy } from '../types.ts';
3
+
4
+ // ── Minimal PNG decoder ──────────────────────────────────────────────────────
5
+ //
6
+ // Playwright viewport screenshots are always non-interlaced 8-bit RGB (color type 2)
7
+ // or RGBA (color type 6). This narrow scope — four filter equations plus zlib inflate —
8
+ // handles that exact case in ~80 lines without a pngjs dependency.
9
+ //
10
+ // Interlaced PNGs, palette PNGs, and bit depths other than 8 throw immediately.
11
+ // Those formats never appear in Playwright output; if one did, the caller's try/catch
12
+ // turns the failure into a null result rather than breaking capture.
13
+
14
+ const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
15
+
16
+ function paethPredictor(a: number, b: number, c: number): number {
17
+ const p = a + b - c;
18
+ const pa = Math.abs(p - a);
19
+ const pb = Math.abs(p - b);
20
+ const pc = Math.abs(p - c);
21
+ if (pa <= pb && pa <= pc) return a;
22
+ if (pb <= pc) return b;
23
+ return c;
24
+ }
25
+
26
+ /**
27
+ * Decode an 8-bit RGB or RGBA PNG buffer into a flat pixel array.
28
+ *
29
+ * Supports color types 2 (RGB) and 6 (RGBA), bit depth 8, non-interlaced only —
30
+ * exactly what Playwright viewport screenshots produce.
31
+ */
32
+ export function decodePng(data: Buffer): {
33
+ width: number;
34
+ height: number;
35
+ pixels: Uint8Array;
36
+ channels: number;
37
+ } {
38
+ for (let i = 0; i < 8; i++) {
39
+ if (data[i] !== PNG_SIG[i]) throw new Error('not a PNG: bad signature');
40
+ }
41
+
42
+ let offset = 8;
43
+ let width = 0;
44
+ let height = 0;
45
+ let channels = 0;
46
+ const idatChunks: Buffer[] = [];
47
+
48
+ while (offset + 12 <= data.length) {
49
+ const chunkLen = data.readUInt32BE(offset);
50
+ const type = data.subarray(offset + 4, offset + 8).toString('ascii');
51
+ const chunk = data.subarray(offset + 8, offset + 8 + chunkLen);
52
+ offset += 12 + chunkLen; // length(4) + type(4) + data(N) + crc(4)
53
+
54
+ if (type === 'IHDR') {
55
+ width = chunk.readUInt32BE(0);
56
+ height = chunk.readUInt32BE(4);
57
+ const bitDepth = chunk[8]!;
58
+ const colorType = chunk[9]!;
59
+ const interlace = chunk[12]!;
60
+ if (bitDepth !== 8) throw new Error(`unsupported PNG bit depth: ${bitDepth} (only 8 supported)`);
61
+ if (colorType === 2) channels = 3;
62
+ else if (colorType === 6) channels = 4;
63
+ else throw new Error(`unsupported PNG color type: ${colorType} (only RGB=2 and RGBA=6 supported)`);
64
+ if (interlace !== 0) throw new Error('interlaced PNG not supported');
65
+ } else if (type === 'IDAT') {
66
+ idatChunks.push(chunk);
67
+ } else if (type === 'IEND') {
68
+ break;
69
+ }
70
+ }
71
+
72
+ if (width === 0 || height === 0 || channels === 0) {
73
+ throw new Error('PNG IHDR not found or has zero dimensions');
74
+ }
75
+
76
+ const stride = width * channels;
77
+ const raw = inflateSync(Buffer.concat(idatChunks));
78
+ const pixels = new Uint8Array(width * height * channels);
79
+ let srcOff = 0;
80
+
81
+ for (let y = 0; y < height; y++) {
82
+ const filterType = raw[srcOff++]!;
83
+ const dstRow = y * stride;
84
+
85
+ for (let x = 0; x < stride; x++) {
86
+ const rawVal = raw[srcOff + x]! & 0xff;
87
+ const a = x >= channels ? pixels[dstRow + x - channels]! : 0;
88
+ const b = y > 0 ? pixels[dstRow - stride + x]! : 0;
89
+ const c = (x >= channels && y > 0) ? pixels[dstRow - stride + x - channels]! : 0;
90
+
91
+ let val: number;
92
+ switch (filterType) {
93
+ case 0: val = rawVal; break; // None
94
+ case 1: val = (rawVal + a) & 0xff; break; // Sub
95
+ case 2: val = (rawVal + b) & 0xff; break; // Up
96
+ case 3: val = (rawVal + Math.floor((a + b) / 2)) & 0xff; break; // Average
97
+ case 4: val = (rawVal + paethPredictor(a, b, c)) & 0xff; break; // Paeth
98
+ default: throw new Error(`unknown PNG filter type: ${filterType}`);
99
+ }
100
+ pixels[dstRow + x] = val;
101
+ }
102
+ srcOff += stride;
103
+ }
104
+
105
+ return { width, height, pixels, channels };
106
+ }
107
+
108
+ const round4 = (v: number): number => Math.round(v * 10000) / 10000;
109
+
110
+ /**
111
+ * Compute per-interval pixel-difference energy from a sequence of PNG frame buffers.
112
+ *
113
+ * Energy = fraction of pixels whose max per-channel RGB difference exceeds
114
+ * `diffThreshold` (default 10, scale 0–255). Alpha is ignored — only the visible RGB
115
+ * signal is compared.
116
+ *
117
+ * Region summary: the frame is split into three equal vertical thirds (top / mid / bottom).
118
+ * `regionFractions[i]` is the fraction of that third's pixels that changed — answering
119
+ * "where did the motion happen" without requiring layout data.
120
+ *
121
+ * IMPORTANT: this function sees ALL pixel-level motion, including GSAP and rAF-driven
122
+ * libraries that `document.getAnimations()` cannot detect. Where
123
+ * `ir.meta.motion.animatedProperties` may read as [] for a GSAP-only page, a non-zero
124
+ * energy curve confirms motion occurred — closing the getAnimations() blind spot documented
125
+ * on MotionMeasurement and Invariants.animatedProperties.
126
+ *
127
+ * Pure and deterministic: given the same PNG buffers and threshold it always returns the
128
+ * same result. Safe to call in unit tests with synthetic buffers.
129
+ *
130
+ * @param buffers Ordered PNG buffers, earliest frame first. Fewer than 2 → empty pairs.
131
+ * @param diffThreshold Per-channel difference threshold (0–255, default 10).
132
+ */
133
+ export function computeEnergy(buffers: Buffer[], diffThreshold = 10): EnergyCurve {
134
+ const frames = buffers.map((b) => decodePng(b));
135
+ const pairs: FrameEnergy[] = [];
136
+
137
+ for (let i = 0; i + 1 < frames.length; i++) {
138
+ const a = frames[i]!;
139
+ const b = frames[i + 1]!;
140
+
141
+ // Dimension mismatch: treat as fully changed (conservative, not a normal case).
142
+ if (a.width !== b.width || a.height !== b.height) {
143
+ pairs.push({ pairIndex: i, changedFraction: 1, regionFractions: [1, 1, 1] });
144
+ continue;
145
+ }
146
+
147
+ const { width, height, channels } = a;
148
+ const totalPixels = width * height;
149
+ const thirdH = Math.floor(height / 3);
150
+ // Bottom region absorbs any remainder from the floor.
151
+ const regionTotal = [thirdH * width, thirdH * width, (height - 2 * thirdH) * width];
152
+ const regionChanged = [0, 0, 0];
153
+ let totalChanged = 0;
154
+
155
+ for (let y = 0; y < height; y++) {
156
+ for (let x = 0; x < width; x++) {
157
+ const idx = (y * width + x) * channels;
158
+ let maxDiff = 0;
159
+ // Compare RGB channels only — ignore alpha (channel index 3) even if present.
160
+ const rgbChannels = Math.min(3, channels);
161
+ for (let ch = 0; ch < rgbChannels; ch++) {
162
+ const diff = Math.abs((a.pixels[idx + ch]! & 0xff) - (b.pixels[idx + ch]! & 0xff));
163
+ if (diff > maxDiff) maxDiff = diff;
164
+ }
165
+ if (maxDiff > diffThreshold) {
166
+ totalChanged++;
167
+ const region = y < thirdH ? 0 : y < thirdH * 2 ? 1 : 2;
168
+ regionChanged[region]!++;
169
+ }
170
+ }
171
+ }
172
+
173
+ const changedFraction = round4(totalChanged / Math.max(totalPixels, 1));
174
+ const regionFractions: [number, number, number] = [
175
+ round4(regionChanged[0]! / Math.max(regionTotal[0]!, 1)),
176
+ round4(regionChanged[1]! / Math.max(regionTotal[1]!, 1)),
177
+ round4(regionChanged[2]! / Math.max(regionTotal[2]!, 1)),
178
+ ];
179
+
180
+ pairs.push({ pairIndex: i, changedFraction, regionFractions });
181
+ }
182
+
183
+ const peakEnergy = pairs.length > 0
184
+ ? round4(Math.max(...pairs.map((p) => p.changedFraction)))
185
+ : 0;
186
+
187
+ return { frames: buffers.length, pairs, peakEnergy };
188
+ }
@@ -0,0 +1,198 @@
1
+ # Image hover distortion
2
+
3
+ An image hover distortion applies a CSS filter shift, clip-path animation, or both when
4
+ the user hovers an image — desaturating it toward monochrome, applying a blur that
5
+ clears on hover, or using a clip-path polygon that contracts to reveal the image at full
6
+ quality. No WebGL, no canvas: the entire effect is CSS, which means it degrades cleanly
7
+ and runs at composited speed.
8
+
9
+ ## When it earns its place / When it does not
10
+
11
+ Condition: a gallery or portfolio grid where the hover state deepens engagement before
12
+ the user clicks. The distortion frames the hover moment as a discovery — the image
13
+ reveals itself to the pointer. Effective on showpiece pages where the image is a subject,
14
+ not a container. `expressive.md`: "A gallery or portfolio grid where the hover state
15
+ deepens engagement with a piece before the user clicks."
16
+
17
+ Condition against: product images where accuracy matters — e-commerce, documentation,
18
+ any context where the image is evidence rather than atmosphere. Distorting a product
19
+ photo misleads the user about what they are buying or reading. Also: navigation
20
+ thumbnails where the user needs to read text overlaid on the image — the distortion
21
+ degrades the legibility of the label. Also: images that are already small (below ~200px
22
+ in either dimension); clip-path animation at small sizes produces visible aliasing.
23
+
24
+ The effect must complete within 150ms for hover onset and 200ms to settle, per
25
+ `core/theory/motion.md`: "The distortion must complete in under 150ms for hover onset
26
+ and under 200ms to settle. Anything slower reads as broken."
27
+
28
+ ## Parameters
29
+
30
+ ```css
31
+ :root {
32
+ /* Filter applied in the resting state (before hover).
33
+ Greyscale + slight desaturation is a common pre-hover treatment. */
34
+ --distort-filter-rest: grayscale(0.6) brightness(0.9);
35
+
36
+ /* Filter in the hover state. Identity = no filter effect. */
37
+ --distort-filter-hover: grayscale(0) brightness(1);
38
+
39
+ /* Duration for filter transitions. Under 150ms for immediate hover onset. */
40
+ --distort-filter-duration: 120ms;
41
+ --distort-filter-ease: var(--ease-out-circ);
42
+
43
+ /* Clip-path inset values: resting (slightly cropped) vs. hover (full reveal). */
44
+ --distort-clip-rest: inset(8% round 4px);
45
+ --distort-clip-hover: inset(0% round 0px);
46
+
47
+ /* Duration for clip-path transition. Can be slightly longer than filter. */
48
+ --distort-clip-duration: 200ms;
49
+ --distort-clip-ease: var(--ease-out-expo);
50
+ }
51
+ ```
52
+
53
+ ## Implementation
54
+
55
+ ```html
56
+ <figure class="gallery-item">
57
+ <div class="image-distort">
58
+ <img src="work-1.jpg" alt="Case study: Meridian brand identity" loading="lazy">
59
+ </div>
60
+ <figcaption>Meridian</figcaption>
61
+ </figure>
62
+ ```
63
+
64
+ ```css
65
+ .image-distort {
66
+ overflow: hidden;
67
+ /* Contain the clip-path change. */
68
+ border-radius: 4px;
69
+ }
70
+
71
+ .image-distort img {
72
+ display: block;
73
+ width: 100%;
74
+ aspect-ratio: 4 / 3;
75
+ object-fit: cover;
76
+
77
+ /* Apply both filter and clip-path in resting state. */
78
+ filter: var(--distort-filter-rest);
79
+ clip-path: var(--distort-clip-rest);
80
+
81
+ transition:
82
+ filter var(--distort-filter-duration) var(--distort-filter-ease),
83
+ clip-path var(--distort-clip-duration) var(--distort-clip-ease);
84
+
85
+ /* Both filter and clip-path are composited via the GPU; no layout is triggered. */
86
+ will-change: filter, clip-path;
87
+ }
88
+
89
+ /* Hover state: full reveal. */
90
+ .image-distort:hover img,
91
+ .image-distort:focus-within img {
92
+ filter: var(--distort-filter-hover);
93
+ clip-path: var(--distort-clip-hover);
94
+ }
95
+
96
+ /* Scale variant: subtle zoom instead of clip-path. Use one or the other. */
97
+ .image-distort--scale img {
98
+ transform: scale(1.04);
99
+ clip-path: none;
100
+ transition:
101
+ filter var(--distort-filter-duration) var(--distort-filter-ease),
102
+ transform var(--distort-clip-duration) var(--distort-clip-ease);
103
+ }
104
+
105
+ .image-distort--scale:hover img,
106
+ .image-distort--scale:focus-within img {
107
+ filter: var(--distort-filter-hover);
108
+ transform: scale(1);
109
+ }
110
+ ```
111
+
112
+ ## React
113
+
114
+ ```tsx
115
+ import { motion } from 'framer-motion';
116
+
117
+ const imageVariants = {
118
+ rest: {
119
+ filter: 'grayscale(0.6) brightness(0.9)',
120
+ clipPath: 'inset(8% round 4px)',
121
+ transition: { duration: 0.12, ease: [0, 0.55, 0.45, 1] }, // ease-out-circ
122
+ },
123
+ hover: {
124
+ filter: 'grayscale(0) brightness(1)',
125
+ clipPath: 'inset(0% round 0px)',
126
+ transition: {
127
+ filter: { duration: 0.12, ease: [0, 0.55, 0.45, 1] },
128
+ clipPath: { duration: 0.20, ease: [0.16, 1, 0.3, 1] }, // ease-out-expo
129
+ },
130
+ },
131
+ };
132
+
133
+ function GalleryItem({
134
+ src,
135
+ alt,
136
+ caption,
137
+ }: {
138
+ src: string;
139
+ alt: string;
140
+ caption: string;
141
+ }) {
142
+ return (
143
+ <motion.figure initial="rest" whileHover="hover" style={{ margin: 0 }}>
144
+ <div style={{ overflow: 'hidden', borderRadius: 4 }}>
145
+ <motion.img
146
+ src={src}
147
+ alt={alt}
148
+ variants={imageVariants}
149
+ style={{ display: 'block', width: '100%', aspectRatio: '4/3', objectFit: 'cover' }}
150
+ loading="lazy"
151
+ />
152
+ </div>
153
+ <figcaption>{caption}</figcaption>
154
+ </motion.figure>
155
+ );
156
+ }
157
+ ```
158
+
159
+ ## Reduced-motion variant
160
+
161
+ In reduced-motion context, the hover state is still distinct — but achieved with an
162
+ instant opacity or border change rather than an animated filter or clip-path transition.
163
+ The image is always fully visible; no distortion is applied.
164
+
165
+ ```css
166
+ @media (prefers-reduced-motion: reduce) {
167
+ .image-distort img {
168
+ filter: none;
169
+ clip-path: none;
170
+ transition: none;
171
+ will-change: auto;
172
+ }
173
+
174
+ /* Provide a non-motion hover signal: a simple opacity change. */
175
+ .image-distort:hover img,
176
+ .image-distort:focus-within img {
177
+ opacity: 0.85;
178
+ }
179
+ }
180
+ ```
181
+
182
+ ## Performance note
183
+
184
+ `filter` and `clip-path` are both GPU-composited when the element has a CSS transform
185
+ or `will-change` applied. The `will-change: filter, clip-path` declaration here is
186
+ justified because these properties actively animate on hover. Unlike `will-change:
187
+ transform` applied broadly, hover-scoped `will-change` promotes the layer only for the
188
+ duration of the interaction — the browser manages this correctly.
189
+
190
+ Do not animate `border-radius` on the image itself — this causes the browser to
191
+ recalculate the clip geometry on every frame. Use `clip-path: inset(... round ...)` to
192
+ achieve rounded corners within a clip-path animation; this stays off-thread. Wrap the
193
+ image in `overflow: hidden` on the parent to handle the visual boundary.
194
+
195
+ `clip-path` animation between two `inset()` values is well-supported and smooth. Avoid
196
+ animating between two `polygon()` clip-paths unless the polygon point count is identical
197
+ in both states — mismatched polygon point counts cause the browser to interpolate via a
198
+ cross-fade rather than a smooth morph, which defeats the purpose of the animation.
@@ -0,0 +1,229 @@
1
+ # Magnetic hover
2
+
3
+ A magnetic hover element attracts the pointer toward its centre as the cursor approaches
4
+ within a defined radius, then snaps back when the cursor exits. The element appears to
5
+ pull the cursor toward it; the cursor appears to pull the element. The effect reads as
6
+ weight and responsiveness in the same gesture.
7
+
8
+ ## When it earns its place / When it does not
9
+
10
+ Condition: a primary interactive element on a showpiece page — a CTA button, a large
11
+ navigation target, a signature link — where the interaction itself is part of the
12
+ experience. The magnet makes the element feel alive and responsive in a way that goes
13
+ beyond hover states. Portfolio sites, agency landing pages, campaign microsites where
14
+ the cursor is a live instrument.
15
+
16
+ Condition against: any context with more than two or three magnetic elements on screen
17
+ simultaneously — multiple magnets competing for the cursor's attention cancel each other
18
+ out and the effect reads as chaotic rather than deliberate. Also: buttons in utility UI
19
+ where click precision matters (the magnet shifts the perceived centre of the target and
20
+ can cause misclicks). Also: mobile devices — there is no pointer to attract.
21
+
22
+ The technique requires JavaScript because CSS cannot read pointer coordinates. It must
23
+ be scoped with `@media (pointer: fine)` so it never activates on touch devices.
24
+
25
+ ## Parameters
26
+
27
+ ```css
28
+ :root {
29
+ /* How far (in px) from the element's edge the magnetic field begins. */
30
+ --magnetic-radius: 80px;
31
+
32
+ /* Maximum displacement of the element's centre, as a fraction of the element's
33
+ half-width/half-height. 0.3 = moves up to 30% of its own size. */
34
+ --magnetic-strength: 0.4;
35
+
36
+ /* Duration of the snap-back when the cursor exits the field. */
37
+ --magnetic-return-duration: 600ms;
38
+ --magnetic-return-ease: var(--ease-out-expo);
39
+
40
+ /* Duration of the active tracking (while cursor is in field). Near-instant
41
+ so it reads as direct response, not as lag. */
42
+ --magnetic-follow-duration: 120ms;
43
+ --magnetic-follow-ease: var(--ease-out-circ);
44
+ }
45
+ ```
46
+
47
+ ## Implementation
48
+
49
+ ```html
50
+ <div class="magnetic-zone" data-magnetic>
51
+ <button class="cta-button">Get started</button>
52
+ </div>
53
+ ```
54
+
55
+ ```css
56
+ .cta-button {
57
+ transition:
58
+ transform var(--magnetic-follow-duration, 120ms) var(--magnetic-follow-ease, ease-out);
59
+ /* will-change is appropriate here: this element is tracked every pointermove
60
+ while the cursor is in the field. Limit to one or two elements. */
61
+ will-change: transform;
62
+ }
63
+
64
+ .cta-button.is-returning {
65
+ transition:
66
+ transform var(--magnetic-return-duration, 600ms) var(--magnetic-return-ease, cubic-bezier(0.16, 1, 0.3, 1));
67
+ }
68
+
69
+ /* Magnetic is desktop-only. On touch, this is a standard button. */
70
+ @media (pointer: coarse) {
71
+ .cta-button {
72
+ transition: none;
73
+ will-change: auto;
74
+ }
75
+ }
76
+ ```
77
+
78
+ ```js
79
+ // Only run on pointer-fine devices.
80
+ if (window.matchMedia('(pointer: fine)').matches) {
81
+ document.querySelectorAll('[data-magnetic]').forEach(attachMagnet);
82
+ }
83
+
84
+ function attachMagnet(zone) {
85
+ const el = zone.querySelector('button, a, [data-magnetic-target]') ?? zone;
86
+
87
+ // Read strength from CSS custom property.
88
+ const strength = parseFloat(
89
+ getComputedStyle(document.documentElement)
90
+ .getPropertyValue('--magnetic-strength')
91
+ ) || 0.4;
92
+
93
+ const radius = parseFloat(
94
+ getComputedStyle(document.documentElement)
95
+ .getPropertyValue('--magnetic-radius')
96
+ ) || 80;
97
+
98
+ function onMove(e) {
99
+ const rect = zone.getBoundingClientRect();
100
+ const cx = rect.left + rect.width / 2;
101
+ const cy = rect.top + rect.height / 2;
102
+ const dx = e.clientX - cx;
103
+ const dy = e.clientY - cy;
104
+ const dist = Math.hypot(dx, dy);
105
+ const fieldRadius = Math.max(rect.width, rect.height) / 2 + radius;
106
+
107
+ if (dist < fieldRadius) {
108
+ el.classList.remove('is-returning');
109
+ const pull = (1 - dist / fieldRadius) * strength;
110
+ const x = dx * pull;
111
+ const y = dy * pull;
112
+ el.style.transform = `translate(${x}px, ${y}px)`;
113
+ } else {
114
+ release();
115
+ }
116
+ }
117
+
118
+ function release() {
119
+ el.classList.add('is-returning');
120
+ el.style.transform = '';
121
+ // Remove is-returning after transition completes.
122
+ el.addEventListener('transitionend', () => el.classList.remove('is-returning'), { once: true });
123
+ }
124
+
125
+ zone.addEventListener('pointermove', onMove, { passive: true });
126
+ zone.addEventListener('pointerleave', release, { passive: true });
127
+ }
128
+ ```
129
+
130
+ ## React
131
+
132
+ ```tsx
133
+ import { useRef, useCallback, useEffect } from 'react';
134
+ import { motion, useSpring, useTransform } from 'framer-motion';
135
+
136
+ function MagneticButton({
137
+ children,
138
+ strength = 0.4,
139
+ radius = 80,
140
+ }: {
141
+ children: React.ReactNode;
142
+ strength?: number;
143
+ radius?: number;
144
+ }) {
145
+ const ref = useRef<HTMLDivElement>(null);
146
+
147
+ // Spring physics give the magnet a more physical feel than a CSS transition.
148
+ const springConfig = { stiffness: 300, damping: 20, mass: 0.5 };
149
+ const x = useSpring(0, springConfig);
150
+ const y = useSpring(0, springConfig);
151
+
152
+ const onMove = useCallback((e: React.PointerEvent) => {
153
+ if (!ref.current) return;
154
+ const rect = ref.current.getBoundingClientRect();
155
+ const cx = rect.left + rect.width / 2;
156
+ const cy = rect.top + rect.height / 2;
157
+ const dx = e.clientX - cx;
158
+ const dy = e.clientY - cy;
159
+ const dist = Math.hypot(dx, dy);
160
+ const fieldRadius = Math.max(rect.width, rect.height) / 2 + radius;
161
+ const pull = Math.max(0, (1 - dist / fieldRadius) * strength);
162
+ x.set(dx * pull);
163
+ y.set(dy * pull);
164
+ }, [strength, radius, x, y]);
165
+
166
+ const onLeave = useCallback(() => {
167
+ x.set(0);
168
+ y.set(0);
169
+ }, [x, y]);
170
+
171
+ return (
172
+ <div
173
+ ref={ref}
174
+ onPointerMove={onMove}
175
+ onPointerLeave={onLeave}
176
+ style={{ display: 'inline-block' }}
177
+ >
178
+ <motion.div style={{ x, y }}>
179
+ {children}
180
+ </motion.div>
181
+ </div>
182
+ );
183
+ }
184
+ ```
185
+
186
+ ## Reduced-motion variant
187
+
188
+ In reduced-motion context, the magnetic behaviour is disabled entirely. The element
189
+ is a standard button with a straightforward hover state. No transform tracking,
190
+ no pointer-move listener.
191
+
192
+ ```css
193
+ @media (prefers-reduced-motion: reduce) {
194
+ .cta-button {
195
+ transition: none !important;
196
+ transform: none !important;
197
+ will-change: auto;
198
+ }
199
+ }
200
+ ```
201
+
202
+ In JS, guard the setup:
203
+
204
+ ```js
205
+ const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
206
+ const pointerFine = window.matchMedia('(pointer: fine)').matches;
207
+
208
+ if (pointerFine && !prefersReduced) {
209
+ document.querySelectorAll('[data-magnetic]').forEach(attachMagnet);
210
+ }
211
+ ```
212
+
213
+ ## Performance note
214
+
215
+ The pointer-move handler updates `transform: translate()` — a compositor-only property.
216
+ The `will-change: transform` on the magnetic element is justified here: the element
217
+ is actively animating on every pointer-move event while the cursor is within the field.
218
+ This is not speculative `will-change`; it is applied to an element that is known to
219
+ animate continuously during a user interaction.
220
+
221
+ Limit magnetic elements to one or two per page. Each active magnetic element with
222
+ `will-change: transform` occupies a compositor layer; more than three simultaneous
223
+ layers on this pattern consumes measurable GPU memory on mobile hardware — even though
224
+ the magnet never activates on mobile, the CSS `will-change` still promotes the layer
225
+ on all breakpoints unless scoped with `@media (pointer: fine)`.
226
+
227
+ The `{ passive: true }` flag on `pointermove` is critical — without it, the browser
228
+ cannot optimise scroll performance because it must wait for the handler to confirm it
229
+ will not call `preventDefault()`.