@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.
- package/LICENSE +21 -0
- package/README.ko.md +282 -0
- package/README.md +282 -0
- package/adapters/build.ts +184 -0
- package/adapters/claude.ts +111 -0
- package/adapters/codex.ts +68 -0
- package/adapters/tokens.ts +21 -0
- package/adapters/tool-map.json +7 -0
- package/bin/omd-install.ts +76 -0
- package/bin/omd.ts +1774 -0
- package/core/asset-sourcing/index.ts +187 -0
- package/core/coach/index.ts +98 -0
- package/core/composition/app-shell-workbench.md +168 -0
- package/core/composition/asymmetric-diagonal-grid.md +178 -0
- package/core/composition/bento-grid.md +223 -0
- package/core/composition/editorial-index-labels.md +173 -0
- package/core/composition/form-wizard-stepper.md +132 -0
- package/core/composition/master-detail-flow.md +129 -0
- package/core/composition/section-inversion.md +182 -0
- package/core/composition/sidebar-margin-annotation.md +184 -0
- package/core/composition/split-screen-hero.md +224 -0
- package/core/composition/sticky-sidebar-scroll.md +219 -0
- package/core/composition/typographic-hero.md +100 -0
- package/core/composition-contract/index.ts +233 -0
- package/core/composition-contract/visual-richness.ts +101 -0
- package/core/config/index.ts +27 -0
- package/core/copy/index.ts +296 -0
- package/core/craft/finish-pass.md +356 -0
- package/core/craft/index.ts +33 -0
- package/core/design/index.ts +581 -0
- package/core/design/interaction-states.ts +189 -0
- package/core/eval-harness/index.ts +225 -0
- package/core/evidence/final.ts +444 -0
- package/core/evidence/task.ts +459 -0
- package/core/figma/client.ts +312 -0
- package/core/figma/diff.ts +261 -0
- package/core/figma/responsive.ts +310 -0
- package/core/figma/system.ts +466 -0
- package/core/figma/types.ts +185 -0
- package/core/frame/check-ux.ts +180 -0
- package/core/frame/index.ts +39 -0
- package/core/frame/write.ts +168 -0
- package/core/graphics/css-illustration-primitives.md +189 -0
- package/core/graphics/duotone-image-presets.md +173 -0
- package/core/graphics/gradient-mesh.md +178 -0
- package/core/graphics/noise-grain-texture.md +150 -0
- package/core/graphics/placeholder-policy.md +185 -0
- package/core/graphics/svg-geometric-patterns.md +145 -0
- package/core/history/index.ts +39 -0
- package/core/install/detect.ts +32 -0
- package/core/install/install.ts +321 -0
- package/core/install/patch-claude.ts +109 -0
- package/core/install/patch-codex.ts +82 -0
- package/core/interaction/index.ts +101 -0
- package/core/interaction/recipes/signature-lighting.md +203 -0
- package/core/ir/dom.ts +352 -0
- package/core/ir/normalize.ts +177 -0
- package/core/motion/easing.md +151 -0
- package/core/motion/energy.ts +188 -0
- package/core/motion/recipes/image-hover-distortion.md +198 -0
- package/core/motion/recipes/magnetic-hover.md +229 -0
- package/core/motion/recipes/marquee.md +202 -0
- package/core/motion/recipes/number-counter.md +242 -0
- package/core/motion/recipes/page-loader.md +240 -0
- package/core/motion/recipes/parallax.md +233 -0
- package/core/motion/recipes/scroll-reveal.md +210 -0
- package/core/motion/recipes/section-color-inversion.md +217 -0
- package/core/motion/recipes/split-text-entrance.md +222 -0
- package/core/motion/recipes/stagger-orchestrator.md +227 -0
- package/core/motion/recipes/sticky-scene-transition.md +246 -0
- package/core/motion/recipes/view-transitions.md +239 -0
- package/core/probe/index.ts +186 -0
- package/core/probe/schema.json +46 -0
- package/core/protocol/composition-contract.md +192 -0
- package/core/protocol/copy-deck.md +96 -0
- package/core/protocol/human-design-loop.md +447 -0
- package/core/protocol/slop-review.md +71 -0
- package/core/ref/batch.ts +94 -0
- package/core/ref/blueprint.ts +199 -0
- package/core/ref/distance.ts +225 -0
- package/core/ref/invariants.ts +162 -0
- package/core/ref/signal.ts +37 -0
- package/core/ref/store.ts +122 -0
- package/core/render/index.ts +607 -0
- package/core/rules/attribution.ts +159 -0
- package/core/rules/builtin/contrast.yaml +8 -0
- package/core/rules/builtin/focus.yaml +8 -0
- package/core/rules/builtin/hit-area.yaml +8 -0
- package/core/rules/builtin/ko.yaml +6 -0
- package/core/rules/builtin/motion.yaml +80 -0
- package/core/rules/builtin/slop.yaml +365 -0
- package/core/rules/builtin/spacing.yaml +9 -0
- package/core/rules/builtin/system.yaml +42 -0
- package/core/rules/builtin/token.yaml +8 -0
- package/core/rules/builtin/ux.yaml +125 -0
- package/core/rules/engine.ts +88 -0
- package/core/rules/leakage.ts +72 -0
- package/core/rules/motion-spec.ts +204 -0
- package/core/site/index.ts +100 -0
- package/core/slop/index.ts +427 -0
- package/core/slop/text-slop.ts +220 -0
- package/core/source-seal/index.ts +174 -0
- package/core/stack/index.ts +53 -0
- package/core/target/index.ts +183 -0
- package/core/theory/color.md +285 -0
- package/core/theory/components.md +272 -0
- package/core/theory/craft.md +270 -0
- package/core/theory/expressive.md +213 -0
- package/core/theory/imagegen.md +105 -0
- package/core/theory/layout.md +159 -0
- package/core/theory/motion.md +283 -0
- package/core/theory/typography.md +134 -0
- package/core/theory/ux.md +580 -0
- package/core/theory/voice.md +595 -0
- package/core/types.ts +684 -0
- package/package.json +40 -0
- package/src/agents/composer.agent.yaml +174 -0
- package/src/agents/eye.agent.yaml +208 -0
- package/src/agents/framer.agent.yaml +52 -0
- package/src/agents/glance.agent.yaml +19 -0
- package/src/agents/hand.agent.yaml +200 -0
- package/src/agents/scout.agent.yaml +71 -0
- package/src/agents/sketch.agent.yaml +64 -0
- package/src/agents/typesetter.agent.yaml +52 -0
- package/src/agents/writer.agent.yaml +63 -0
- package/src/skills/omd-coach/SKILL.md +40 -0
- package/src/skills/omd-critique/SKILL.md +77 -0
- package/src/skills/omd-figma/SKILL.md +256 -0
- package/src/skills/omd-humanize/SKILL.md +160 -0
- package/src/skills/omd-scout/SKILL.md +77 -0
- 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()`.
|