All patterns

Connect Integration

The switch throws, the status swaps to connected, and the row opens to list what it will now sync.

onboardingfuturisticfriendlyinteraction · finite · starter · ~0.5s
Interactive · click to play
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.

369 lines · react + motion only
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.

Integration setupOnboarding flowSettings rowPermission scope reveal

Where it shows up

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

  • Ridgeline
    Settings
    General
    Notifications
    Members
    Billing
    SettingsNew
    Desktop notificationsAlert on mention and reply
    Weekly digestEvery Monday at 09:00
    SoundsPlay a tone for new messages
    Follow repliesTrack threads you post in
    Settings

    Enabling an app expands the row to list the permissions it will use.

Related patterns