All patterns

Trust This Device

Flipping the switch drops the device into the trusted list below, already marked.

authenticationminimalcalminteraction · finite · intermediate · ~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.

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

/**
 * Vibary · Trust This Device
 *
 * Flipping the switch does not just change a setting — the device drops
 * into the trusted list below, already marked, so the consequence of the
 * toggle is visible in the same glance as the toggle itself.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Neutrals mix from the inherited text color, so the panel reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `deviceName`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type DeviceTrustToggleProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Label of the row that appears when the switch goes on. */
  deviceName?: string;
  /** Second line of that row. */
  deviceDetail?: string;
  /** Switch and check color. */
  accent?: string;
  /** Fires on every flip with the new state. */
  onTrustChange?: (trusted: boolean) => void;
};

type VariantConfig = {
  /** How far the new row travels before it lands, in px. */
  rise: number;
  /** How long the list takes to open the gap for it, in seconds. */
  open: number;
  /** Beat between the row landing and its check drawing. */
  markDelay: number;
  knob: { type: "spring"; stiffness: number; damping: number };
  row: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: both springs sit above a 0.8 damping ratio. A knob that
// overshoots its track reads as a broken switch rather than a lively
// one, and the row carries text. Variants change pace and travel only.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // For security settings that should feel like record-keeping.
  subtle: {
    rise: 5,
    open: 0.16,
    markDelay: 0.05,
    knob: { type: "spring", stiffness: 620, damping: 42 },
    row: { type: "spring", stiffness: 540, damping: 44 },
  },
  // Enough travel to draw the eye down to the list. The all-purpose one.
  default: {
    rise: 9,
    open: 0.22,
    markDelay: 0.1,
    knob: { type: "spring", stiffness: 520, damping: 38 },
    row: { type: "spring", stiffness: 420, damping: 38 },
  },
  // The row visibly arrives from above the list.
  playful: {
    rise: 14,
    open: 0.28,
    markDelay: 0.14,
    knob: { type: "spring", stiffness: 440, damping: 34 },
    row: { type: "spring", stiffness: 360, damping: 32 },
  },
};

/** Theme-adaptive neutral: `currentColor` is the inherited text color —
 *  near-black on a light page, near-white on a dark one — so mixing it
 *  with `transparent` yields a surface, border or fill that is correctly
 *  toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const ROW_HEIGHT = 46;

const KNOWN_DEVICES = [
  { name: "iPhone 15 · Safari", detail: "Last used 2 days ago" },
  { name: "Windows · Edge", detail: "Last used 28 July" },
];

export default function DeviceTrustToggle({
  variant = "default",
  deviceName = "MacBook Pro · Chrome",
  deviceDetail = "This device · added just now",
  accent = "#5B5BD6",
  onTrustChange,
}: DeviceTrustToggleProps) {
  const [trusted, setTrusted] = useState(false);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const flip = () => {
    const next = !trusted;
    setTrusted(next);
    onTrustChange?.(next);
  };

  // Reduced motion keeps the whole consequence — the row still appears,
  // still marked — and drops only the travel and the gap opening.
  const rowMotion = reduceMotion
    ? {
        initial: { opacity: 0, height: ROW_HEIGHT },
        animate: { opacity: 1, height: ROW_HEIGHT },
        exit: { opacity: 0, height: 0 },
        transition: { duration: 0.15, ease: "easeOut" as const },
      }
    : {
        initial: { opacity: 0, height: 0, y: -cfg.rise },
        animate: { opacity: 1, height: ROW_HEIGHT, y: 0 },
        exit: { opacity: 0, height: 0, y: -cfg.rise },
        transition: {
          y: cfg.row,
          // Height is a genuine size change — the list opening a gap —
          // so it tweens. Springing a height leaves the rows below
          // rocking, which is the one thing a settings list must not do.
          height: { duration: cfg.open, ease: "easeOut" as const },
          opacity: { duration: 0.18, ease: "easeOut" as const },
        },
      };

  return (
    <div
      style={{
        width: 320,
        padding: 16,
        borderRadius: 16,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
      }}
    >
      <div style={{ fontSize: 11, fontWeight: 650, letterSpacing: 0.5, opacity: 0.5 }}>
        SECURITY
      </div>

      <div
        style={{
          display: "flex",
          alignItems: "flex-start",
          justifyContent: "space-between",
          gap: 14,
          marginTop: 10,
        }}
      >
        <div>
          <div style={{ fontSize: 13.5, fontWeight: 600 }}>Trust this device</div>
          <div style={{ fontSize: 12, opacity: 0.55, marginTop: 3, lineHeight: 1.45 }}>
            Skip the sign-in code here for 30 days
          </div>
        </div>

        <button
          type="button"
          role="switch"
          aria-checked={trusted}
          aria-label="Trust this device"
          onClick={flip}
          style={{
            flexShrink: 0,
            width: 44,
            height: 26,
            padding: 3,
            marginTop: 1,
            borderRadius: 999,
            border: `1px solid ${trusted ? "transparent" : tone(16)}`,
            background: trusted ? accent : tone(10),
            color: "inherit",
            cursor: "pointer",
            transition: "background-color 180ms ease, border-color 180ms ease",
            display: "flex",
            justifyContent: "flex-start",
          }}
        >
          {/* The knob is the only thing that travels: an 18px slide on a
              tight spring, so the switch answers the finger immediately. */}
          <motion.span
            aria-hidden
            animate={{ x: trusted ? 18 : 0 }}
            transition={reduceMotion ? { duration: 0 } : cfg.knob}
            style={{
              display: "block",
              width: 18,
              height: 18,
              borderRadius: 999,
              background: "#FFFFFF",
              boxShadow: "0 1px 3px rgba(0,0,0,0.28)",
            }}
          />
        </button>
      </div>

      <div
        style={{
          marginTop: 14,
          paddingTop: 12,
          borderTop: `1px solid ${tone(10)}`,
          fontSize: 11,
          fontWeight: 650,
          letterSpacing: 0.5,
          opacity: 0.5,
        }}
      >
        TRUSTED DEVICES
      </div>

      <div style={{ marginTop: 2 }}>
        {/* initial={false} so the two standing devices do not replay the
            entrance on mount — only the row the switch just added moves. */}
        <AnimatePresence initial={false}>
          {trusted && (
            <motion.div key="this-device" {...rowMotion} style={{ overflow: "hidden" }}>
              <DeviceRow
                name={deviceName}
                detail={deviceDetail}
                accent={accent}
                markDelay={reduceMotion ? 0 : cfg.markDelay}
                reduceMotion={Boolean(reduceMotion)}
              />
            </motion.div>
          )}
        </AnimatePresence>

        {KNOWN_DEVICES.map((device) => (
          <DeviceRow key={device.name} name={device.name} detail={device.detail} />
        ))}
      </div>
    </div>
  );
}

function DeviceRow({
  name,
  detail,
  accent,
  markDelay = 0,
  reduceMotion = false,
}: {
  name: string;
  detail: string;
  accent?: string;
  markDelay?: number;
  reduceMotion?: boolean;
}) {
  return (
    <div
      style={{
        height: ROW_HEIGHT,
        display: "flex",
        alignItems: "center",
        gap: 10,
        borderBottom: `1px solid ${tone(8)}`,
      }}
    >
      <span
        aria-hidden
        style={{
          display: "grid",
          placeItems: "center",
          width: 26,
          height: 26,
          flexShrink: 0,
          borderRadius: 8,
          background: tone(8),
          opacity: 0.75,
        }}
      >
        <svg
          width="14"
          height="14"
          viewBox="0 0 20 20"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.6"
          strokeLinecap="round"
          strokeLinejoin="round"
        >
          <rect x="2.5" y="4" width="15" height="9.5" rx="1.6" />
          <path d="M7 16.5h6" />
        </svg>
      </span>

      <div style={{ minWidth: 0, flex: 1 }}>
        <div
          style={{
            fontSize: 12.5,
            fontWeight: 600,
            overflow: "hidden",
            textOverflow: "ellipsis",
            whiteSpace: "nowrap",
          }}
        >
          {name}
        </div>
        <div style={{ fontSize: 11.5, opacity: 0.5, marginTop: 2 }}>{detail}</div>
      </div>

      {accent && (
        <span
          aria-label="Trusted"
          style={{
            display: "grid",
            placeItems: "center",
            width: 20,
            height: 20,
            flexShrink: 0,
            borderRadius: 999,
            background: accent,
            color: "#FFFFFF",
          }}
        >
          {/* The check draws rather than appears: a stroke that arrives
              after the row has landed reads as the system confirming,
              not as decoration that came with the row. */}
          <svg width="12" height="12" viewBox="0 0 20 20" fill="none">
            <motion.path
              d="M5.5 10.4l3 3 6-6.4"
              stroke="currentColor"
              strokeWidth="2.1"
              strokeLinecap="round"
              strokeLinejoin="round"
              initial={{ pathLength: reduceMotion ? 1 : 0 }}
              animate={{ pathLength: 1 }}
              transition={
                reduceMotion
                  ? { duration: 0 }
                  : { duration: 0.24, delay: markDelay, ease: "easeOut" }
              }
            />
          </svg>
        </span>
      )}
    </div>
  );
}

About this pattern

A switch that only changes its own colour leaves the user guessing what it did. Here the knob slides on a tight spring and the list underneath opens a gap for the device in the same beat, so the setting and its consequence are visible in one glance. The row travels a short distance into the gap while the list height tweens — a genuine size change, so it eases rather than springs, otherwise every row below would rock. The trusted check draws a fraction later, which reads as the system confirming rather than as decoration that arrived with the row. Switching back closes the gap the same way.

Remember this device settingTwo-factor security panelSession and device managementAccount settings switch

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

    Session and device lists that update in place as a preference changes.

Related patterns