Logo Morph
One SVG shape that travels between several logo or icon paths — normalised at runtime so the points pair up, and cross-fading instead of tearing when they cannot.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/logo-morph.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "LogoMorph" component — ONE <path> element
that travels between several logo or icon paths, on a timer or on hover. Its
only dependency is a cn() class merger (clsx + tailwind-merge); no animation
library, no path-morphing library.
Contract
- export const LogoMorph = React.forwardRef<HTMLSpanElement, LogoMorphProps>;
Props extend React.ComponentPropsWithoutRef<"span"> minus children (rest props
spread onto the root span, ref goes to the root) plus:
- paths: (string | { d: string; label?: string })[] — the marks in order. A
bare `d` string is a decorative mark; the object form names it.
- viewBox?: string (default "0 0 24 24") — the coordinate system every path
is authored in. All paths share it; that IS the normalisation of scale.
- interval?: number (default 2400) — ms a shape is held before the next
morph starts; ignored while trigger="manual".
- duration?: number (default 640) — ms one morph takes.
- easing?: "linear" | "ease-in" | "ease-out" | "ease-in-out" | "back-out" |
((t: number) => number) (default "ease-in-out"). back-out overshoots past 1
and settles back.
- trigger?: "auto" | "hover" | "manual" (default "auto") — what drives the
sequence.
- index?: number — the target shape while trigger="manual"; ignored otherwise.
- onShapeChange?: (index: number) => void — fires when a morph lands.
- label?: string — the accessible name. WITHOUT it the graphic is aria-hidden:
a morphing logo is decoration until the consumer says otherwise.
- mode?: "fill" | "stroke" (default "fill"), strokeWidth?: number (default 2),
fillRule?: "nonzero" | "evenodd" (default "nonzero") — filled marks vs
lucide-style line art, and counters (letter holes) that must stay open.
- "use client": rAF, matchMedia, observers, DOM writes.
- Every numeric prop is clamped and non-finite values fall back to the default;
entries with an empty `d` are dropped, and a list of 0 or 1 shapes simply
never animates instead of dividing by zero.
Behavior — normalisation is the whole component
- Parse each `d` into contours of ABSOLUTE CUBIC segments: M/L/H/V/C/S/Q/T/A/Z
all become "start point + a list of cubics + closed?". Lines become cubics
with controls at 1/3 and 2/3; quadratics are lifted exactly; S and T reflect
the previous control; an elliptical arc is converted through the endpoint ->
centre parameterisation into at most one cubic per quadrant (icon sets round
every corner with `a`, and dropping arcs would send half the real world down
the fallback path); Z materialises the closing edge back to the start, which
is what makes a ring genuinely cyclic.
- Write the parser as a CHARACTER SCANNER, not a number tokenizer: arc flags are
single characters and every minifier glues them to what follows ("a1 1 0 011
1"), which a split-on-numbers pass silently reads as 011. Unparseable input
returns null rather than throwing — the caller just cross-fades.
- To morph A into B: they must have the same number of contours and each pair
must agree on open vs closed. Then, per contour, grow the sparser one to the
other's segment count by subdividing with de Casteljau, spreading the extra
cuts evenly so a 3-segment triangle keeps its corners against a 10-segment
star. For CLOSED contours, rotate B's start index to whichever anchor
minimises the summed squared distance to A's — without that, two identical
rings authored from different corners send every point the long way round and
the shape visibly winds up. Open contours keep their start point: there the
first point is meaningful.
- Interpolate the flattened numbers into a preallocated Float64Array and
reserialise "M ... C ... Z" each frame. Nothing else in the loop allocates.
- FALLBACK, and it is a feature: when the pair cannot be put on one skeleton
(different contour counts, one open one closed, or past a segment budget such
as 512), cross-fade the two ORIGINAL `d` strings on two stacked paths instead
of tearing. Do it SEQUENTIALLY — out fully, then in — because two copies of
the same ink overlapping at 50% would darken where they cross.
- Triggers. auto: hold `interval`, morph `duration`, next index, forever.
hover: the timer is off; entering with a fine pointer (or focusing) skips the
rest of the dwell so the answer is immediate, and it keeps cycling while held.
Leaving mid-morph lets the current morph FINISH and then rests — a mark frozen
half way between two logos looks broken. manual: the loop only runs while
`index` differs from the settled shape, and there is NO dwell — the reader
already decided, so a click must not sit out an interval before moving. A new
`index` arriving mid-morph must NOT restart the loop from the last settled
shape: keep the target in a ref the running loop reads, and let the travel in
flight finish and then head for the new target.
- Focus parity without inventing a tab stop: listen for focusin/focusout on the
document (rAF-coalesced), resolve the nearest focusable element in the chain
once per check — root.closest("a[href],button,[tabindex]"), which is the root
itself when the consumer passed tabIndex — and treat "that element contains
document.activeElement" as hovered. A mark sitting inside somebody else's <a>
then lights up when that link is tabbed to; testing the root alone would miss
it, because an ancestor is not contained by its own descendant (and testing
the other direction would match <body>, i.e. always).
- The loop closes itself: while resting, the rAF chain is simply not re-armed,
so a settled mark holds zero frame slots. An IntersectionObserver and
visibilitychange stop it off screen and in a hidden tab, and dt is clamped
(~64ms) so a resumed tab cannot teleport the morph.
- React owns the settled `d`, the loop owns the in-between. Render
d={paths[settledIndex]} so the mark is in the server HTML and survives with JS
off; write `d` imperatively during a morph; on landing, reset BOTH paths to
the rest state (d + opacity) before setState, or an imperative opacity left
behind would never be corrected — React only writes props that changed.
- prefers-reduced-motion: reduce — read through matchMedia with
useSyncExternalStore (server snapshot false, so hydration matches and a
mid-session toggle is respected). Under reduce it HOLDS THE FIRST PATH: no
loop, no listeners, mark fully painted. A controlled `index` still moves, it
just lands in one paint instead of travelling.
- Pointer reality: matchMedia("(pointer: coarse)") turns trigger="hover" into
"auto" — there is no hover on touch, and a mark that flashes under a thumb is
noise. Nothing captures the pointer, calls preventDefault or sets touch-action,
so scrolling over the mark is untouched. There is no pointermove listener at
all: enter and leave are the entire gesture.
- Cleanup on unmount: the rAF, the coalescing rAF, the IntersectionObserver, the
visibilitychange listener and both focus listeners. The matchMedia listeners
are torn down by useSyncExternalStore's subscribe.
Rendering & styling
- Semantic tokens only, zero colour literals. The paths paint with
currentColor, so the consumer picks the ink with a text token on the root
(text-primary, or a chart token for a decorative mark); the root also carries
focus-visible:ring-2 focus-visible:ring-ring for the case where a consumer
makes it focusable. cn() merges className over a `size-12` default, so
`className="size-40"` is all a hero needs.
- The <svg> keeps overflow visible, because `back-out` deliberately sends points
outside the viewBox for a moment.
- Accessibility: without `label` the whole graphic is aria-hidden — decoration
by default. With it, the svg becomes role="img" with that name, and a
visually-hidden aria-live="polite" region (a SIBLING of the svg, never a child
— children of role="img" are ignored by screen readers) names the mark. Arm
that region only outside trigger="auto" — an unattended timer must not narrate
itself every two seconds — and feed it from the shape the loop came to REST
on, not from every landing: a hover that is still being held keeps cycling,
and one sentence per lap is the same noise the timer was denied. It starts
empty, so nothing is announced on mount. Everything that answers hover answers
focus.
Customization levers
- Rhythm: interval is the dwell, duration the travel. 2400/640 reads as a
brand cycle; 900/520 with trigger="hover" reads as a control answering you;
interval={0} makes it morph continuously, and trigger="manual" ignores the
dwell entirely.
- Curve: easing is the character. "ease-in-out" is neutral, "back-out"
overshoots and lands with weight, a custom (t) => number lets you drop in a
spring or a stepped curve.
- Trigger: "auto" for a hero, "hover" for a nav mark or an icon button, "manual"
to bind the mark to your own state (selected plan, tour step, active tab).
- Ink and size: colour is currentColor, so a text token on the root themes it;
className carries the size; mode="stroke" + strokeWidth turns the same engine
into line art (a morphing sparkline is three open polylines).
- Shape list: sequence order is the animation order — reorder `paths` to change
the story. Authoring notes that decide quality: give every mark the same
contour count and the same winding direction, and keep them in one viewBox.
A pair that disagrees still works, it just cross-fades.
- Budget: the segment cap (512) is the guard against a per-frame rebuild of a
hundred-contour illustration; lower it to force cross-fades on phones.Concepts
- Normalisation before interpolation — two
dstrings can only be tweened if they say the same thing in the same words. Every command is rewritten as an absolute cubic, the sparser contour is subdivided until both carry the same number of points, and only then does anything move. - Ring alignment — a closed contour has no natural first point. The start index of the incoming ring is rotated to whichever anchor sits nearest the outgoing one, which is the difference between a shape that flows and a shape that visibly winds up on its way over.
- Cross-fade instead of tearing — a ring and three dots are not the same kind of drawing. When the structures cannot be paired the component fades the original paths instead, sequentially, so two copies of the same ink never overlap and darken.
- Advance, not play — hover and focus do not scrub a timeline, they permit the next step, and focus is measured against the nearest focusable ancestor so a mark inside somebody else's link answers that link's Tab stop instead of inventing one. A morph already in flight when the pointer leaves always finishes: half a logo is a bug, a settled logo is a state.
- Retarget, never restart — a controlled
indexthat changes mid-morph is a new destination, not a new loop. The target is read through a ref, so the travel already under way lands before the mark sets off again; rebuilding the loop instead would snap the outline back to the last settled shape in full view of the reader. - Decorative by default — no
label, no accessible name,aria-hiddenon the whole graphic. Give it a name and it becomesrole="img", and only then, and only once the loop actually comes to rest — never on an unattended timer, never once per lap of a hover you are still holding — is the shape it stopped on announced politely. - Holds the first path with motion off — under
prefers-reduced-motionno loop is ever started and no listeners are attached; the mark is fully painted, and a controlled index still lands, in one paint instead of over 600ms.
Scroll Velocity Skew
A wrapper that leans and slightly scales its content in proportion to scroll velocity, then eases back to neutral the moment scrolling stops.
Wave Divider
A live seam between two sections — stacked wave layers re-sampled every frame from two counter-travelling harmonics, with an opaque front wave painted in the next section's own token.