feat(catalog): add motion-blur component#1274
Conversation
Velocity-driven directional motion blur using SVG filter ghost copies. Ghost-copy approach gives one-sided trail; feGaussianBlur top layer keeps element blurry during movement. Uses tween onUpdate pattern (not tl.eventCallback) so it works in the HyperFrames headless renderer. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
miguel-heygen
left a comment
There was a problem hiding this comment.
Clean addition — the SVG filter pipeline is solid, the onUpdate-over-eventCallback trick is the right call for HyperFrames renderer compatibility (same pattern as morph-text/liquid-glass-notification), and the per-axis velocity math is correct.
Three things to address before merge:
P1 — registry-item.json description is factually wrong
registry/components/motion-blur/registry-item.json line 5:
"Velocity-driven motion blur — samples element position each frame and scales CSS blur + horizontal stretch proportionally to speed"
The implementation uses SVG feGaussianBlur, not CSS filter: blur(). That description gets ingested by the catalog index and any tooling that reads registry manifests — it should say "SVG filter" (or "feGaussianBlur ghost trail"). The horizontal stretch part is also inaccurate for the default config since stretchMax defaults to 0.
P2 — attachMotionBlur implementation diverges between the installable snippet and the example
registry/components/motion-blur/motion-blur.html (canonical):
var stretchMax = opts.stretchMax !== undefined ? opts.stretchMax : 0;registry/examples/motion-blur/index.html (example):
var stretchMax = opts.stretchMax !== undefined ? opts.stretchMax : 0.5;The example also drops the stretchMax > 0 cleanup block from the deceleration branch. Both are non-fatal here because the example call passes stretchMax: 0 explicitly — but if someone copies from the example to write new code they'll pick up a 0.5 default and a leaky cleanup path. The example should match the canonical snippet exactly (or import it, since the same 150-line function body is duplicated verbatim across two files).
P3 (nit) — window._hfMbUid write is a no-op
var _hfMbUid = (window._hfMbUid = window._hfMbUid || 0);The closure captures _hfMbUid and increments it locally; window._hfMbUid is set once at IIFE evaluation and never written back. If the IIFE re-evaluates (e.g. in future multi-snippet scenarios), the counter resets and could produce duplicate filter IDs. Fix:
if (!window._hfMbUid) window._hfMbUid = 0;
// inside attachMotionBlur:
var uid = "hf-mb-" + window._hfMbUid++;Harmless today, worth getting right before other motion components do the same pattern.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Walked the snippet, demo, example, MDX, registry-item, and the docs.json / catalog-index.json plumbing. Technique choice is the right one — SVG filter (vs. canvas or WebGL) avoids the HTML-in-canvas pattern the team's been moving away from, and tl.to(proxy, { onUpdate }) matches the established morph-text / liquid-glass-notification pattern for getting per-seek callbacks past the renderer's eventCallback/call stripping. Three-ghost backward-only trail composition is physically motivated — no spurious forward blur. Big chunk of the +2587 / -1151 is just the catalog-index.json regen (alphabetically rewritten on add), so the actual review surface is small.
Concerns
[registry-item.json description is factually wrong] Line 6 of registry/components/motion-blur/registry-item.json:
"description": "Velocity-driven motion blur — samples element position each frame and scales CSS blur + horizontal stretch proportionally to speed"
Two factual errors vs. the actual implementation:
- "CSS blur" — actually SVG
feGaussianBlurinside a<filter>chain. Not the same primitive. Users grepping the codebase forfilter: blur(...)(CSS) will find nothing. - "horizontal stretch" —
stretchScaleapplies to both x and y axes (seemotion-blur.html:191-194); also, stretch is disabled by default (stretchMaxdefaults to0in the snippet, line 44).
The MDX (motion-blur.mdx:5) has the correct description ("velocity-driven directional motion blur — samples element position each frame and applies a one-sided ghost trail proportional to speed"). Copying that into registry-item.json fixes both issues. This blurb is what CLI users see when npx hyperframes add motion-blur runs and what the catalog grid surfaces — worth getting right before merge.
[default-drift between the three independent copies of attachMotionBlur] The snippet, the demo, and the example each define their own attachMotionBlur. That's the catalog pattern (each render artifact has to be self-contained), but the copies have already diverged:
| File | stretchMax default |
|---|---|
registry/components/motion-blur/motion-blur.html:44 |
0 |
registry/components/motion-blur/demo.html:~330 (inlined) |
(matches snippet, OK) |
registry/examples/motion-blur/index.html:343 |
0.5 |
Visible runtime behavior is the same because both demo and example call sites pass an explicit { stretchMax: 0 } override (line 538 in examples/motion-blur/index.html, line 517 in demo.html). So no user-visible behavior bug today.
But a future maintainer stepping into the example's inlined attachMotionBlur to debug will see a different default than the canonical snippet, and the next person dropping the explicit override (or adding a fourth artifact) gets surprised. The canonical default of 0 is correct per the snippet's design intent ("off by default; enable via stretchMax > 0 if you want the effect"). Make all three copies match.
[contract gap: attachMotionBlur silently owns scaleX/scaleY/transformOrigin when stretchMax > 0] In motion-blur.html:191-196 (and the reset path at line 206 and 222), the snippet calls:
gsap.set(s.el, {
scaleX: axis !== "y" ? sx : 1,
scaleY: axis !== "x" ? sy : 1,
transformOrigin: axis === "x" ? ox : axis === "y" ? oy : "50% 50%",
});…on every frame the velocity is non-trivial, and unconditionally resets to { scaleX: 1, scaleY: 1, transformOrigin: "50% 50%" } on idle. If the user's own GSAP timeline animates scaleX/scaleY or sets a custom transformOrigin on the same element (e.g. a scaling pop on entrance, a non-center anchor for rotation), the motion-blur tracking tween will race / clobber those values.
This isn't necessarily a bug — when stretchMax: 0 (the default) the gsap.set calls never fire. But the contract isn't stated. Two ways to surface it:
- (a) Add a sentence to the top-of-file docstring: "When
stretchMax > 0, attachMotionBlur ownsscaleX/scaleY/transformOriginon the target element — do not animate those properties from your own timeline on motion-blurred elements." - (b) Bail out of the
gsap.setcalls when the velocity-derived stretch is below a threshold so idle elements don't get their scale clobbered (aif (sx > 1.001 || sy > 1.001)guard).
Either works. (a) is the cheaper fix and matches the snippet's "explicit requirements" framing at lines 18-23.
[duration-extension requirement is documented only in the inline example comment, not the top-of-file docstring] The top-of-file requirements list (lines 18-23) says "Call attachMotionBlur() AFTER defining all tweens, before window.__timelines registration." — but doesn't mention the tl.set(document.body, {}, DATA_DURATION) extension step. That's only flagged in the inline example comment at the very bottom of the file (lines 245-246) and in the MDX docs.
The failure mode here is the gnarly kind: if a user calls attachMotionBlur without extending the timeline to DATA_DURATION first, the tracking tween's duration captures whatever tl.duration() returned at that moment, the onUpdate stops firing past that point, and the ghost trail freezes (or never appears for late-seek frames). Silent, no warning, looks like "motion blur just doesn't work on this composition."
Adding a 7th bullet to the Requirements block at the top of the file — "Extend the timeline to DATA_DURATION (tl.set(document.body, {}, DATA_DURATION)) BEFORE calling attachMotionBlur — the tracking tween snapshots tl.duration() at attach time" — would catch the most common foot-gun before users hit it.
Questions
[prefers-reduced-motion policy across catalog components] Motion blur is the kind of effect that an accessibility-sensitive user would want disabled. The headless renderer doesn't care, but the Studio preview and any user installing this on a live web page does. What's the catalog precedent? If the team's stance is "catalog effects don't gate on reduced-motion because the user opted in by adding the component", that's a defensible answer — but if other catalog components do honor it, this one should too.
If the policy is "honor it", the fix is one line near the top of attachMotionBlur:
if (typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
return;
}[N-target perf scaling] Each target gets a 14-primitive SVG filter (3 ghosts × 3 primitives + 1 top blur + 1 feMerge with 4 nodes), 1 <svg> element appended to document.body, and per-frame setAttribute calls on feOffset.dx/dy and feGaussianBlur.stdDeviation. The example renders 10 targets simultaneously (lines 535-539) at 60fps and CI is green, so empirically that's fine in the headless renderer. Have you stress-tested in Studio with the live preview? Just want to confirm the scaling holds in the interactive path, not just the offline render.
Nits
[_hfMbUid cleanup] Re-calling attachMotionBlur on the same composition (e.g. on Studio hot-reload of the snippet) appends a new <svg> + filter without removing the old one. For one-shot HF renders this is zero practical impact; for Studio live editing it's a slow leak. Not worth blocking on, but a future detachMotionBlur (or auto-cleanup keyed on filterId) would be a clean follow-up.
[magic numbers in the velocity → blur conversion] The constants in motion-blur.html:158-184 — 0.5 (per-ghost stdDev scale), 0.08/0.04 (cross-axis floor), 0.15 (top-blur scale), 0.3 (activity threshold), 0.4 (cross-axis minimum) — are all tuned for visual feel. A one-line comment block listing them with their role would help the next person retuning the effect (or porting to a different velocity range).
What I didn't verify
- The actual rendered output — couldn't open the 203KB MP4 inline; trusting CI's claim that frame-by-frame blur appears.
- Behavior in the Studio live-preview path (only verified the static snippet code and the renderer-path contract via the matching
morph-textpattern). - Behavior under low-memory-safe-mode (recent #1225 / #1235 work). The SVG filter approach should be cheap in memory terms but I didn't grep for
safe-modeintegration points. - Mintlify rendering of the MDX preview locally. PR body confirms author verified at
localhost:3002.
Verdict
Solid catalog addition. Technique choice is right, sibling-pattern alignment is right, the demo is a genuinely good showcase of the velocity → blur curve. The one description bug in registry-item.json is a 1-line fix and worth catching before merge for catalog UX correctness; the contract / duration-requirement gaps in the snippet docstring are doc-only and would save users from the most common failure modes. Leaving as a comment.
— Review by Rames D Jusso
- registry-item.json: fix description — SVG feGaussianBlur ghost trail, not CSS blur/horizontal stretch - motion-blur.html/demo.html/index.html: fix _hfMbUid counter — use window._hfMbUid++ directly so re-evaluation doesn't reset and produce duplicate filter IDs - demo.html/index.html: fix stretchMax default (0.5 → 0) and add missing stretchMax > 0 apply/cleanup blocks to match canonical snippet Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
miguel-heygen
left a comment
There was a problem hiding this comment.
✅ Fixed
All three issues from my previous review are resolved in the fix commit (e139321):
- registry-item.json description — now correctly says "SVG feGaussianBlur ghost trail"
stretchMaxdefault drift — all three copies now default to0; missingstretchMax > 0apply/cleanup blocks added to demo and example_hfMbUidcounter — switched towindow._hfMbUid++directly, counter persists across re-evaluations
Rames D Jusso's review covered additional surface (docstring gaps, prefers-reduced-motion question, scaleX/scaleY ownership contract). Those are all doc/comment-level — no code correctness issues remain. I'd defer to Vance on the reduced-motion policy question; if that's out of scope for catalog components, marking this approved.
Approved.

What
Adds
motion-bluras a new catalog component — velocity-driven directional motion blur for elements animated via GSAP x/y.Why
Common polished video effect; previously no built-in solution in HyperFrames. SVG filter approach works in-browser without canvas or WebGL.
How
SVG filter pipeline (per target element):
feOffset → feGaussianBlur → feComponentTransfer) placed only behind the element, offset proportional to velocity — inherently one-sided, no forward blurfeGaussianBlurat current position (topfeMergelayer) keeps the element itself blurry during motionblurMaxTimeline integration:
Uses
tl.to(proxy, { onUpdate })instead oftl.eventCallback("onUpdate")— the HyperFrames renderer proxies the GSAP timeline and stripseventCallback/call. TweenonUpdatefires on every seek in both Studio and the headless renderer (same pattern asmorph-textandliquid-glass-notification).Files:
registry/components/motion-blur/motion-blur.html— installable snippet (npx hyperframes add motion-blur)registry/components/motion-blur/demo.html— catalog preview compositionregistry/examples/motion-blur/index.html— Studio-facing exampledocs/catalog/components/motion-blur.mdx— catalog page with video previewTest plan
demo.htmlvia headless producer — 203KB animated MP4 (confirmed blur appears frame-by-frame)localhost:3002