Skip to content

feat(catalog): add motion-blur component#1274

Merged
vanceingalls merged 3 commits into
mainfrom
feat/catalog-motion-blur-component
Jun 8, 2026
Merged

feat(catalog): add motion-blur component#1274
vanceingalls merged 3 commits into
mainfrom
feat/catalog-motion-blur-component

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

What

Adds motion-blur as 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):

  • Three ghost copies (feOffset → feGaussianBlur → feComponentTransfer) placed only behind the element, offset proportional to velocity — inherently one-sided, no forward blur
  • Small feGaussianBlur at current position (top feMerge layer) keeps the element itself blurry during motion
  • All ghost + blur sigma values scale linearly with velocity, clamped to blurMax

Timeline integration:
Uses tl.to(proxy, { onUpdate }) instead of tl.eventCallback("onUpdate") — the HyperFrames renderer proxies the GSAP timeline and strips eventCallback/call. Tween onUpdate fires on every seek in both Studio and the headless renderer (same pattern as morph-text and liquid-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 composition
  • registry/examples/motion-blur/index.html — Studio-facing example
  • docs/catalog/components/motion-blur.mdx — catalog page with video preview

Test plan

  • Rendered demo.html via headless producer — 203KB animated MP4 (confirmed blur appears frame-by-frame)
  • Catalog preview video + poster uploaded to S3 and linked in MDX
  • Mintlify local preview verified at localhost:3002
  • oxlint + oxfmt clean on all changed files

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>
@mintlify

mintlify Bot commented Jun 8, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
hyperframes 🟢 Ready View Preview Jun 8, 2026, 6:39 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

vanceingalls commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. "CSS blur" — actually SVG feGaussianBlur inside a <filter> chain. Not the same primitive. Users grepping the codebase for filter: blur(...) (CSS) will find nothing.
  2. "horizontal stretch"stretchScale applies to both x and y axes (see motion-blur.html:191-194); also, stretch is disabled by default (stretchMax defaults to 0 in 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 owns scaleX/scaleY/transformOrigin on the target element — do not animate those properties from your own timeline on motion-blurred elements."
  • (b) Bail out of the gsap.set calls when the velocity-derived stretch is below a threshold so idle elements don't get their scale clobbered (a if (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-1840.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-text pattern).
  • 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-mode integration 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 miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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"
  • stretchMax default drift — all three copies now default to 0; missing stretchMax > 0 apply/cleanup blocks added to demo and example
  • _hfMbUid counter — switched to window._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.

@vanceingalls vanceingalls merged commit 1fd1b31 into main Jun 8, 2026
38 checks passed
@vanceingalls vanceingalls deleted the feat/catalog-motion-blur-component branch June 8, 2026 07:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants