Skip to content

feat(registry): add SVG Morph component#80

Open
alejopequeno wants to merge 5 commits intomainfrom
alejo/svg-morph
Open

feat(registry): add SVG Morph component#80
alejopequeno wants to merge 5 commits intomainfrom
alejo/svg-morph

Conversation

@alejopequeno
Copy link
Contributor

@alejopequeno alejopequeno commented Feb 13, 2026

Result

New svg-morph registry component that morphs SVG path d attributes with auto-looping. Supports multiple morph slots (each cycling through its own path states), static paths for compound shapes, and a transform prop for coordinate system conversion.

  • Component: registry/joyco/blocks/svg-morph.tsx — uses flubber for path interpolation + Motion for animation
  • Demo: Animated face with morphing eyes and mouth (3 expression states)
  • Docs: Full props table, usage example, auto-looping explanation, and limitations section

Install via:

npx shadcn@latest add @joyco/svg-morph

Dependencies added automatically: flubber, motion.

Media

Screen.Recording.2026-02-12.at.11.24.49.PM.mov

Greptile Summary

This PR adds a new svg-morph registry component that smoothly morphs SVG path d attributes using flubber for interpolation and motion for animation. The implementation is split cleanly into an AutoMorphPath (self-looping) and a ControlledMorphPath (externally driven by a step prop), along with a companion useSvgMorph hook. The mid-animation interruption handling in ControlledMorphPath is a thoughtful touch. Documentation is thorough.

Two bugs were found in the component:

  • Runtime crash on out-of-bounds step: In ControlledMorphPath, if step >= paths.length, paths[step] resolves to undefined. Both the useRef initialization and the useEffect can then pass undefined into flubber.interpolate, throwing a runtime error. The useSvgMorph hook guards against this internally, but users in controlled mode who pass step directly are exposed.
  • Infinite re-render loop on empty paths: In AutoMorphPath, passing an empty paths array causes loopedPaths = [undefined]. The onComplete callback always satisfies pathIndex === loopedPaths.length - 1, so setPathIndex increments unboundedly, freezing the UI. A simple if (paths.length === 0) return null guard resolves this.

Confidence Score: 3/5

  • Safe to merge after addressing the two runtime crash/infinite-loop edge cases in the main component.
  • The happy path (valid paths arrays, clamped step values) works correctly and the implementation is well-structured. However, two bugs can cause hard crashes or infinite re-renders under common misuse conditions: passing an out-of-bounds step to ControlledMorphPath throws immediately via flubber, and passing an empty paths array to AutoMorphPath produces an unbounded re-render loop. Both are straightforward to fix with short guard clauses.
  • Pay close attention to registry/joyco/blocks/svg-morph.tsx — specifically the AutoMorphPath empty-array edge case (line 31) and the ControlledMorphPath out-of-bounds step handling (lines 70 and 79).

Important Files Changed

Filename Overview
registry/joyco/blocks/svg-morph.tsx Main SVG morph component with AutoMorphPath and ControlledMorphPath sub-components. Has a potential runtime crash when step is out of bounds and when paths is an empty array.
hooks/use-svg-morph.ts Well-implemented hook for controlled mode; correctly clamps step values within valid range using useCallback and useState.
demos/svg-morph-demo.tsx Clean auto-looping demo with a face expression animation using three morph slots.
demos/svg-morph-controlled-demo.tsx Controlled step demo with prev/next/direct navigation buttons; correctly uses useSvgMorph hook for clamped step management.
content/components/svg-morph.mdx Comprehensive docs covering props, auto-loop behavior, controlled mode, useSvgMorph API, and limitations (fill-only, no compound paths, synchronized timing).

Last reviewed commit: 3d93fba

@vercel
Copy link
Contributor

vercel bot commented Feb 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
joyco-hub Ready Ready Preview, Comment Feb 13, 2026 8:57pm

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

13 files reviewed, 4 comments

Edit Code Review Agent Settings | Greptile

Import useMemo and cache loopedPaths and indices to avoid recreating
arrays on every render. Update the effect to depend on the memoized
loopedPaths so animations update reliably when paths change.
Implement a step prop that switches the component to controlled
mode and animates directly to the target step (preserving mid-
animation state). Add a useSvgMorph hook to manage step state
(setStep/next/prev/isFirst/isLast), include a controlled demo, and
update docs and registry metadata to advertise the new mode and hook.
@matiasperz
Copy link
Contributor

@greptileai

Copy link
Contributor

@matiasperz matiasperz left a comment

Choose a reason for hiding this comment

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

I think this api should be controlled by default instead of including code on it's core to auto-reproduce.

Comment on lines +70 to +79
const currentPathRef = useRef(paths[step])
const interpolatorRef = useRef<FlubberInterpolator | null>(null)

const d = useTransform(progress, (v: number) => {
if (!interpolatorRef.current) return currentPathRef.current
return interpolatorRef.current(v)
})

useEffect(() => {
const targetPath = paths[step]
Copy link
Contributor

Choose a reason for hiding this comment

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

Out-of-bounds step causes a runtime crash

When step >= paths.length, paths[step] is undefined. This means currentPathRef is initialized with undefined (line 70), and the effect's targetPath also becomes undefined (line 79). Passing undefined into flubber.interpolate on line 89 will throw a runtime error.

The useSvgMorph hook correctly clamps step, but a user in controlled mode who passes step directly without the hook (e.g. from external state) can easily trigger this crash — especially when paths arrays across different morph slots have different lengths or when step is derived from dynamic data.

Consider adding a guard at the top of the effect:

useEffect(() => {
  const targetPath = paths[step]
  if (targetPath === undefined || targetPath === currentPathRef.current) return
  // ... rest of the effect
}, [step, duration, progress, paths])

And similarly protect the useRef initialization:

const currentPathRef = useRef(paths[Math.min(step, paths.length - 1)] ?? '')
Prompt To Fix With AI
This is a comment left during a code review.
Path: registry/joyco/blocks/svg-morph.tsx
Line: 70-79

Comment:
**Out-of-bounds `step` causes a runtime crash**

When `step >= paths.length`, `paths[step]` is `undefined`. This means `currentPathRef` is initialized with `undefined` (line 70), and the effect's `targetPath` also becomes `undefined` (line 79). Passing `undefined` into `flubber.interpolate` on line 89 will throw a runtime error.

The `useSvgMorph` hook correctly clamps `step`, but a user in controlled mode who passes `step` directly without the hook (e.g. from external state) can easily trigger this crash — especially when `paths` arrays across different morph slots have different lengths or when `step` is derived from dynamic data.

Consider adding a guard at the top of the effect:

```tsx
useEffect(() => {
  const targetPath = paths[step]
  if (targetPath === undefined || targetPath === currentPathRef.current) return
  // ... rest of the effect
}, [step, duration, progress, paths])
```

And similarly protect the `useRef` initialization:

```tsx
const currentPathRef = useRef(paths[Math.min(step, paths.length - 1)] ?? '')
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +31 to +35
const loopedPaths = useMemo(() => [...paths, paths[0]], [paths])
const indices = useMemo(() => loopedPaths.map((_, i) => i), [loopedPaths])
const d = useTransform(progress, indices, loopedPaths, {
mixer: (a, b) => interpolate(a, b, { maxSegmentLength: 20 }),
})
Copy link
Contributor

Choose a reason for hiding this comment

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

Empty paths array causes an infinite re-render loop

When paths = [], paths[0] is undefined, so loopedPaths becomes [undefined] and indices becomes [0]. The useEffect then runs animate(progress, 0, ...), which immediately completes (zero displacement). In onComplete, the condition pathIndex === loopedPaths.length - 1 (i.e. 0 === 0) is always true, so it calls progress.set(0) and setPathIndex(1). The next effect run calls animate(progress, 1, ...), which completes and calls setPathIndex(2), and so on — pathIndex increments indefinitely, freezing the UI.

Add an early-return guard at the top of AutoMorphPath:

function AutoMorphPath({ paths, duration, gap, fill }) {
  if (paths.length === 0) return null
  // ... rest of the component
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: registry/joyco/blocks/svg-morph.tsx
Line: 31-35

Comment:
**Empty `paths` array causes an infinite re-render loop**

When `paths = []`, `paths[0]` is `undefined`, so `loopedPaths` becomes `[undefined]` and `indices` becomes `[0]`. The `useEffect` then runs `animate(progress, 0, ...)`, which immediately completes (zero displacement). In `onComplete`, the condition `pathIndex === loopedPaths.length - 1` (i.e. `0 === 0`) is always `true`, so it calls `progress.set(0)` and `setPathIndex(1)`. The next effect run calls `animate(progress, 1, ...)`, which completes and calls `setPathIndex(2)`, and so on — `pathIndex` increments indefinitely, freezing the UI.

Add an early-return guard at the top of `AutoMorphPath`:

```tsx
function AutoMorphPath({ paths, duration, gap, fill }) {
  if (paths.length === 0) return null
  // ... rest of the component
}
```

How can I resolve this? If you propose a fix, please make it concise.

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.

2 participants