Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/* ---------------------------------------------------------------------------
 * Path normalisation
 *
 * Two `d` strings can only be tweened point for point if they say the same
 * thing in the same words. Everything below rewrites an arbitrary path into the
 * one form we can interpolate — absolute cubics, one list per subpath — and
 * then makes two of those forms structurally identical. It is pure maths, no
 * DOM, so it runs happily inside a memo on the server as well.
 * ------------------------------------------------------------------------- */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/logo-morph.json

Prompt

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 d strings 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 index that 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-hidden on the whole graphic. Give it a name and it becomes role="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-motion no 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.

On This Page