Connect Integration
The switch throws, the status swaps to connected, and the row opens to list what it will now sync.
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 { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Connect Integration
*
* Flipping the switch does two things at once: the row commits to
* "connected", and the space underneath opens to say what that now
* means. Turning it off takes both away again.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `name`, `syncs`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ConnectIntegrationToggleProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Name of the service in the row. */
name?: string;
/** What the service is, in a few words. */
summary?: string;
/** The lines revealed once it is connected. */
syncs?: string[];
/** Whether the row starts connected. */
defaultConnected?: boolean;
/** Switch and detail color. */
accent?: string;
/** Fires with the new state on every flip. */
onChange?: (connected: boolean) => void;
};
type VariantConfig = {
knob: { type: "spring"; stiffness: number; damping: number };
/** How long the panel takes to open, in seconds. */
openSeconds: number;
/** Gap between revealed lines, in seconds. */
lineStagger: number;
/** How far each line rises as it arrives, in px. */
rise: number;
};
// Quality rule: the knob is the only thing allowed to spring, and it is
// at or above a 0.8 damping ratio so it lands in the socket instead of
// rattling in it. The panel is a height tween because opening genuinely
// is a size change, and the lines inside fade at a constant size.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Fast switch, quick reveal. For a settings list of a dozen rows.
subtle: {
knob: { type: "spring", stiffness: 620, damping: 44 },
openSeconds: 0.22,
lineStagger: 0.04,
rise: 4,
},
// The switch lands, then the detail arrives. The all-purpose setting.
default: {
knob: { type: "spring", stiffness: 500, damping: 38 },
openSeconds: 0.28,
lineStagger: 0.06,
rise: 6,
},
// A heavier throw and a longer reveal, for a single hero integration.
playful: {
knob: { type: "spring", stiffness: 380, damping: 32 },
openSeconds: 0.34,
lineStagger: 0.09,
rise: 9,
},
};
/** Neutral surfaces are mixed from the inherited text color, so the card
* reads correctly on a light page and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SAMPLE_SYNCS = [
"Meetings appear on your timeline",
"Free and busy times stay in sync",
"Notes attach to the right event",
];
const TRACK_WIDTH = 42;
const TRACK_HEIGHT = 24;
const KNOB_SIZE = 18;
export default function ConnectIntegrationToggle({
variant = "default",
name = "Calendar",
summary = "Meetings and availability",
syncs = SAMPLE_SYNCS,
defaultConnected = false,
accent = "#5B5BD6",
onChange,
}: ConnectIntegrationToggleProps) {
const [connected, setConnected] = useState(defaultConnected);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const toggle = () => {
const next = !connected;
setConnected(next);
onChange?.(next);
};
return (
<div
style={{
width: 320,
padding: 6,
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(6),
boxSizing: "border-box",
}}
>
<div
style={{
padding: 12,
borderRadius: 14,
background: tone(connected ? 5 : 0),
transition: "background 240ms ease-out",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 11 }}>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
flex: "none",
width: 34,
height: 34,
borderRadius: 10,
border: `1px solid ${tone(12)}`,
background: tone(8),
}}
>
<svg width="17" height="17" viewBox="0 0 20 20" fill="none">
<rect
x="2.8"
y="4.2"
width="14.4"
height="13"
rx="3"
stroke="currentColor"
strokeWidth="1.4"
opacity="0.55"
/>
<path
d="M2.8 8.2h14.4M6.8 2.8v2.6M13.2 2.8v2.6"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
opacity="0.55"
/>
<motion.rect
x="6.2"
y="10.4"
width="3.4"
height="3.4"
rx="1"
fill={accent}
animate={{ opacity: connected ? 1 : 0.25 }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
</svg>
</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontSize: 14, fontWeight: 650 }}>
{name}
</span>
{/* The status swaps in a fixed-height slot, so the row's
baseline never shifts as the word changes. */}
<span
style={{
position: "relative",
display: "block",
height: 16,
marginTop: 1,
fontSize: 11.5,
overflow: "hidden",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={connected ? "on" : "off"}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{
position: "absolute",
left: 0,
top: 0,
whiteSpace: "nowrap",
color: connected ? accent : "inherit",
opacity: connected ? 1 : 0.5,
}}
>
{connected ? "Connected" : summary}
</motion.span>
</AnimatePresence>
</span>
</span>
<button
type="button"
role="switch"
aria-checked={connected}
aria-label={`Connect ${name}`}
onClick={toggle}
style={{
flex: "none",
position: "relative",
width: TRACK_WIDTH,
height: TRACK_HEIGHT,
padding: 0,
borderRadius: 999,
border: "none",
cursor: "pointer",
color: "inherit",
background: connected ? accent : tone(18),
transition: "background 220ms ease-out",
}}
>
<motion.span
aria-hidden
animate={{
x: connected ? TRACK_WIDTH - KNOB_SIZE - 3 : 3,
}}
transition={reduceMotion ? { duration: 0.12 } : cfg.knob}
style={{
position: "absolute",
top: (TRACK_HEIGHT - KNOB_SIZE) / 2,
left: 0,
width: KNOB_SIZE,
height: KNOB_SIZE,
borderRadius: 999,
background: "Canvas",
boxShadow: "0 1px 3px rgba(0,0,0,0.28)",
}}
/>
</button>
</div>
{/* A genuine size change, so it is a height tween — short, eased,
and the lines inside never scale with it. */}
<motion.div
initial={false}
animate={{ height: connected ? "auto" : 0, opacity: connected ? 1 : 0 }}
transition={{
duration: reduceMotion ? 0 : cfg.openSeconds,
ease: "easeOut",
}}
style={{ overflow: "hidden" }}
>
<div
style={{
marginTop: 12,
paddingTop: 11,
borderTop: `1px solid ${tone(10)}`,
}}
>
<div
style={{
fontSize: 10.5,
fontWeight: 650,
letterSpacing: 0.4,
textTransform: "uppercase",
opacity: 0.4,
marginBottom: 7,
}}
>
Now syncing
</div>
{syncs.map((line, index) => (
<motion.div
key={line}
initial={false}
animate={
connected
? { opacity: 1, y: 0 }
: { opacity: 0, y: reduceMotion ? 0 : cfg.rise }
}
transition={{
duration: reduceMotion ? 0.12 : 0.26,
delay: connected && !reduceMotion ? 0.1 + index * cfg.lineStagger : 0,
ease: "easeOut",
}}
style={{
display: "flex",
alignItems: "center",
gap: 8,
height: 24,
fontSize: 12,
}}
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none">
<path
d="M3.6 8.4 6.6 11.4 12.4 5.2"
stroke={accent}
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span style={{ opacity: 0.65 }}>{line}</span>
</motion.div>
))}
</div>
</motion.div>
</div>
{/* A second row, left alone, so the first one reads as part of a
list rather than as the whole screen. */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 11,
padding: 12,
opacity: 0.45,
}}
>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
flex: "none",
width: 34,
height: 34,
borderRadius: 10,
border: `1px solid ${tone(12)}`,
background: tone(8),
}}
>
<svg width="17" height="17" viewBox="0 0 20 20" fill="none">
<path
d="M3 6.4a2.4 2.4 0 0 1 2.4-2.4h3.1l1.6 2.2h4.5A2.4 2.4 0 0 1 17 8.6v5.2a2.4 2.4 0 0 1-2.4 2.4H5.4A2.4 2.4 0 0 1 3 13.8Z"
stroke="currentColor"
strokeWidth="1.4"
opacity="0.55"
/>
</svg>
</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontSize: 14, fontWeight: 650 }}>
File storage
</span>
<span style={{ display: "block", marginTop: 1, fontSize: 11.5 }}>
Documents and attachments
</span>
</span>
<span
aria-hidden
style={{
flex: "none",
width: TRACK_WIDTH,
height: TRACK_HEIGHT,
borderRadius: 999,
background: tone(18),
}}
/>
</div>
</div>
);
}About this pattern
Connecting a service is a promise, so the row makes the promise visible. The knob springs across its track and settles without rattling, the status line swaps in a fixed-height slot so the row's baseline never moves, and the space beneath opens to name the three things that will now stay in sync — each line arriving a fraction after the one above it. Turning the switch off takes the panel and the promise back with it, which is the part most implementations skip. The panel is the one height tween here, because opening genuinely is a size change; everything inside it fades at a constant size.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Settings
Enabling an app expands the row to list the permissions it will use.
Related patterns
- Import Data ConnectA line draws itself between two service marks, records run along it, and the destination takes a check when the import lands.
- Goal PickerChosen goal tiles paint themselves and take a mark, and the continue action rises the moment the first one is picked.
- Invite Team SendEach address leaves the compose box and travels into a waiting seat, then marks itself sent.