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.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
Two app marks joined by an animated link while an account is connected.
Related patterns
- Connect IntegrationThe switch throws, the status swaps to connected, and the row opens to list what it will now sync.
- Profile Completion RingThe ring around the avatar advances one arc per field, and each row marks itself as the arc reaches it.
- Setup CompleteThe last item checks itself off, the checklist clears, and a single confirmation takes its place.