All patterns

Import Data Connect

A line draws itself between two service marks, records run along it, and the destination takes a check when the import lands.

onboardingfuturisticpremiumautomatic · finite · intermediate · ~2.4s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

436 lines · react + motion only
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Import Data Connect
 *
 * Two services are linked: a line draws itself between their marks,
 * records run along it, and the destination takes a check once the
 * import lands.
 *
 * Self-contained: depends only on `react` and `motion`. Both marks are
 * invented placeholders drawn as inline SVG — swap them for your own.
 * Neutrals are mixed from the inherited text color, so it reads on light
 * and dark pages alike. Works with zero props; tune via `variant`,
 * `source`, `destination`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ImportDataConnectProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Name under the left mark. */
  source?: string;
  /** Name under the right mark. */
  destination?: string;
  /** What is being moved, for the status line. */
  summary?: string;
  /** Line, packet and check color. */
  accent?: string;
  /** Fires once the import has landed. */
  onComplete?: () => void;
};

type Phase = "arriving" | "linking" | "flowing" | "done";

type VariantConfig = {
  /** Seconds the connector takes to draw. */
  drawSeconds: number;
  /** Seconds one record takes to cross. */
  packetSeconds: number;
  /** Seconds between records. */
  packetStagger: number;
  /** How long records keep running before the import lands, in ms. */
  flowMs: number;
  markSpring: { type: "spring"; stiffness: number; damping: number };
  checkSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: damping ratios (damping / 2√stiffness) stay at or above
// 0.8. The only thing that scales is a mark or a badge — both shapes.
// Variants change pace and dwell, never bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Brisk. For an import that usually finishes instantly.
  subtle: {
    drawSeconds: 0.34,
    packetSeconds: 0.62,
    packetStagger: 0.16,
    flowMs: 1100,
    markSpring: { type: "spring", stiffness: 520, damping: 46 },
    checkSpring: { type: "spring", stiffness: 540, damping: 40 },
  },
  // Long enough to watch a few records cross. All-purpose.
  default: {
    drawSeconds: 0.46,
    packetSeconds: 0.8,
    packetStagger: 0.22,
    flowMs: 1700,
    markSpring: { type: "spring", stiffness: 420, damping: 40 },
    checkSpring: { type: "spring", stiffness: 460, damping: 36 },
  },
  // A deliberate transfer, for a first-run migration.
  playful: {
    drawSeconds: 0.6,
    packetSeconds: 1,
    packetStagger: 0.26,
    flowMs: 2200,
    markSpring: { type: "spring", stiffness: 340, damping: 34 },
    checkSpring: { type: "spring", stiffness: 380, damping: 33 },
  },
};

/** Inner width of the connector, in px. Fixed so the drawn stroke keeps
 *  a constant weight — a percentage-width SVG would stretch it. */
const LINE_WIDTH = 152;
const PACKETS = [0, 1, 2];

/** Theme-adaptive neutral: mixing the text color in scope with
 *  `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function ImportDataConnect({
  variant = "default",
  source = "Cadence",
  destination = "Meridian",
  summary = "1,248 records",
  accent = "#5B5BD6",
  onComplete,
}: ImportDataConnectProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [phase, setPhase] = useState<Phase>("arriving");

  // One chain of timers drives the whole sequence: marks land, the line
  // draws, records run, the import settles.
  useEffect(() => {
    const drawAt = reduceMotion ? 60 : 260;
    const flowAt = drawAt + (reduceMotion ? 40 : cfg.drawSeconds * 1000);
    const doneAt = flowAt + (reduceMotion ? 300 : cfg.flowMs);

    const timers = [
      setTimeout(() => setPhase("linking"), drawAt),
      setTimeout(() => setPhase("flowing"), flowAt),
      setTimeout(() => {
        setPhase("done");
        onComplete?.();
      }, doneAt),
    ];
    return () => timers.forEach(clearTimeout);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [cfg, reduceMotion]);

  const linked = phase !== "arriving";
  const done = phase === "done";

  return (
    <div
      style={{
        width: 320,
        boxSizing: "border-box",
        padding: "22px 20px 18px",
        borderRadius: 18,
        border: `1px solid ${tone(12)}`,
        background: tone(5),
      }}
    >
      <div
        style={{
          display: "flex",
          alignItems: "flex-start",
          justifyContent: "space-between",
        }}
      >
        <Mark
          name={source}
          delay={0}
          cfg={cfg}
          reduceMotion={Boolean(reduceMotion)}
          glyph="source"
          accent={accent}
        />

        <div
          aria-hidden
          style={{
            position: "relative",
            width: LINE_WIDTH,
            height: 44,
            marginTop: 8,
          }}
        >
          <svg
            width={LINE_WIDTH}
            height="20"
            viewBox={`0 0 ${LINE_WIDTH} 20`}
            fill="none"
            style={{ position: "absolute", top: 0, left: 0 }}
          >
            <path
              d={`M2 10 H${LINE_WIDTH - 2}`}
              stroke="currentColor"
              strokeOpacity="0.16"
              strokeWidth="2"
              strokeLinecap="round"
              strokeDasharray="3 5"
            />
            {/* pathLength animates the dash offset, so the connector is
                written across the gap rather than faded in. */}
            <motion.path
              d={`M2 10 H${LINE_WIDTH - 2}`}
              stroke={accent}
              strokeWidth="2"
              strokeLinecap="round"
              initial={{ pathLength: 0 }}
              animate={{ pathLength: linked ? 1 : 0 }}
              transition={{
                duration: reduceMotion ? 0 : cfg.drawSeconds,
                ease: "easeInOut",
              }}
            />
          </svg>

          {/* Records only exist while they are crossing: when the phase
              ends they unmount, so nothing loops forever behind a
              finished import. */}
          {phase === "flowing" && !reduceMotion && (
            <div style={{ position: "absolute", top: 0, left: 0 }}>
              {PACKETS.map((packet) => (
                <motion.span
                  key={packet}
                  initial={{ x: 2, opacity: 0 }}
                  animate={{
                    x: LINE_WIDTH - 10,
                    opacity: [0, 1, 1, 0],
                  }}
                  transition={{
                    duration: cfg.packetSeconds,
                    delay: packet * cfg.packetStagger,
                    repeat: Infinity,
                    repeatDelay: cfg.packetStagger,
                    ease: "linear",
                    opacity: {
                      duration: cfg.packetSeconds,
                      times: [0, 0.15, 0.85, 1],
                      delay: packet * cfg.packetStagger,
                      repeat: Infinity,
                      repeatDelay: cfg.packetStagger,
                    },
                  }}
                  style={{
                    position: "absolute",
                    top: 6,
                    left: 0,
                    width: 8,
                    height: 8,
                    borderRadius: 999,
                    background: accent,
                  }}
                />
              ))}
            </div>
          )}

          <div
            style={{
              position: "absolute",
              top: 24,
              left: 0,
              right: 0,
              height: 16,
              display: "grid",
              placeItems: "center",
              overflow: "hidden",
            }}
          >
            <AnimatePresence initial={false}>
              <motion.span
                key={done ? "done" : "working"}
                initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8 }}
                animate={{ opacity: 1, y: 0 }}
                exit={
                  reduceMotion
                    ? { opacity: 0 }
                    : { opacity: 0, y: -8, transition: { duration: 0.16 } }
                }
                transition={{ duration: 0.22, ease: "easeOut" }}
                style={{
                  position: "absolute",
                  fontSize: 11,
                  fontWeight: 600,
                  letterSpacing: 0.2,
                  whiteSpace: "nowrap",
                  opacity: 0.55,
                }}
              >
                {done ? `${summary} imported` : "Reading your data"}
              </motion.span>
            </AnimatePresence>
          </div>
        </div>

        <Mark
          name={destination}
          delay={reduceMotion ? 0 : 0.08}
          cfg={cfg}
          reduceMotion={Boolean(reduceMotion)}
          glyph="destination"
          accent={accent}
          badge={done}
        />
      </div>

      <div
        style={{
          marginTop: 18,
          paddingTop: 12,
          borderTop: `1px solid ${tone(9)}`,
          display: "flex",
          alignItems: "center",
          gap: 8,
          fontSize: 11.5,
          opacity: 0.6,
        }}
      >
        <span>
          {done
            ? "Contacts, notes and files are in place."
            : "Bringing your existing work across."}
        </span>
      </div>
    </div>
  );
}

function Mark({
  name,
  delay,
  cfg,
  reduceMotion,
  glyph,
  accent,
  badge = false,
}: {
  name: string;
  delay: number;
  cfg: VariantConfig;
  reduceMotion: boolean;
  glyph: "source" | "destination";
  accent: string;
  badge?: boolean;
}) {
  return (
    <div style={{ width: 62, textAlign: "center" }}>
      <motion.div
        initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.86 }}
        animate={{ opacity: 1, scale: 1 }}
        transition={
          reduceMotion
            ? { duration: 0.2, ease: "easeOut" }
            : { ...cfg.markSpring, delay }
        }
        style={{
          position: "relative",
          width: 48,
          height: 48,
          margin: "0 auto",
          borderRadius: 14,
          display: "grid",
          placeItems: "center",
          border: `1px solid ${tone(12)}`,
          background: tone(8),
        }}
      >
        {glyph === "source" ? <SourceMark /> : <DestinationMark accent={accent} />}

        <AnimatePresence>
          {badge && (
            <motion.span
              key="badge"
              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0 }}
              transition={
                reduceMotion ? { duration: 0.16, ease: "easeOut" } : cfg.checkSpring
              }
              style={{
                position: "absolute",
                right: -4,
                bottom: -4,
                width: 20,
                height: 20,
                borderRadius: 999,
                display: "grid",
                placeItems: "center",
                background: "#2FA36B",
                boxShadow: "0 2px 6px rgba(0,0,0,0.18)",
              }}
            >
              <svg width="11" height="11" viewBox="0 0 12 12" fill="none">
                <path
                  d="M2.6 6.3 5 8.7l4.4-5"
                  stroke="#ffffff"
                  strokeWidth="1.9"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </motion.span>
          )}
        </AnimatePresence>
      </motion.div>
      <div
        style={{
          marginTop: 7,
          fontSize: 11,
          fontWeight: 600,
          letterSpacing: 0.1,
          opacity: 0.6,
        }}
      >
        {name}
      </div>
    </div>
  );
}

/** Invented service marks. These stand in for whatever you are importing
 *  from and into — deliberately generic shapes, not anyone's logo. */
function SourceMark() {
  return (
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden>
      <rect
        x="4"
        y="4"
        width="16"
        height="16"
        rx="5"
        stroke="currentColor"
        strokeOpacity="0.55"
        strokeWidth="1.6"
      />
      <path
        d="M8.6 15.4v-3M12 15.4V9.4M15.4 15.4v-4.6"
        stroke="currentColor"
        strokeOpacity="0.8"
        strokeWidth="1.7"
        strokeLinecap="round"
      />
    </svg>
  );
}

function DestinationMark({ accent }: { accent: string }) {
  return (
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden>
      <circle cx="12" cy="12" r="8.2" stroke={accent} strokeWidth="1.7" />
      <ellipse cx="12" cy="12" rx="3.8" ry="8.2" stroke={accent} strokeWidth="1.7" />
      <path
        d="M4 9.4h16M4 14.6h16"
        stroke={accent}
        strokeWidth="1.7"
        strokeLinecap="round"
      />
    </svg>
  );
}

About this pattern

Moving an account's existing work into a new product, shown as a link being made rather than a spinner being watched. The two marks settle first, then the connector is written across the gap with an animated pathLength, then records run along it on a linear loop — linear because a record in transit has no reason to ease. The loop exists only while the transfer is running: when the phase ends the packets unmount, so nothing keeps circulating behind a finished import. The destination takes a check on a spring and the status line swaps at a constant font size. Both marks are invented placeholders, deliberately generic shapes rather than anyone's logo.

Data import stepService connectionMigration progressAccount linking

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Set up your workspaceStep 2 of 4
    What should we call it?
    Ridgeline
    Who else is joining?
    3 invited
    Next
    Onboarding flow

    Two app marks joined by an animated link while an account is connected.

Related patterns