All patterns

Follow Button Swap

Follow becomes Following in place — the button never changes width.

socialfriendlyminimalinteraction · finite · starter · ~0.3s
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.

231 lines · react + motion only
import { useState, type CSSProperties } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Follow Button Swap
 *
 * Follow becomes Following in place. Both labels live in the same grid
 * cell, so the button is permanently as wide as the longer one and the
 * row behind it never reflows — the hardest part of this control is
 * layout, not motion.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The followed state is mixed from the inherited text color, so it reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `name`, `handle`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type FollowButtonSwapProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Uncontrolled starting state. */
  defaultFollowing?: boolean;
  name?: string;
  handle?: string;
  /** Two letters shown on the avatar disc. */
  initials?: string;
  /** Disc color behind the initials — stands in for a photo. */
  tint?: string;
  /** Fires on every toggle, with the new state. */
  onFollowingChange?: (following: boolean) => void;
};

type VariantConfig = {
  /** px each label travels as it hands over. */
  travel: number;
  swapSeconds: number;
  drawSeconds: number;
};

// Labels are text, so they translate and fade — never scale, never
// bounce. Variants differ in travel and speed only; a tween keeps a word
// from wobbling, which no spring setting can promise.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a slide. For a list of profiles where several may be tapped.
  subtle: { travel: 2, swapSeconds: 0.16, drawSeconds: 0.17 },
  // Enough travel to read as a hand-over. All-purpose.
  default: { travel: 5, swapSeconds: 0.22, drawSeconds: 0.26 },
  // A longer slide and a slower draw, for a profile header where the
  // follow is the point of the screen.
  playful: { travel: 8, swapSeconds: 0.28, drawSeconds: 0.35 },
};

const ACCENT = "#7C7CF0";
const EASE = [0.32, 0.72, 0, 1] as const;

/** 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 FollowButtonSwap({
  variant = "default",
  defaultFollowing = false,
  name = "Nadia Rehman",
  handle = "@nadia.rehman",
  initials = "NR",
  tint = "#3FA98B",
  onFollowingChange,
}: FollowButtonSwapProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [following, setFollowing] = useState(defaultFollowing);

  // Reduced motion: the labels still hand over, they just stop
  // travelling to do it. The state is carried by the words either way.
  const travel = reduceMotion ? 0 : cfg.travel;
  const swap = {
    duration: reduceMotion ? 0.14 : cfg.swapSeconds,
    ease: EASE,
  };

  const toggle = () => {
    const next = !following;
    setFollowing(next);
    onFollowingChange?.(next);
  };

  const labelStyle: CSSProperties = {
    // Same grid cell for both labels: the cell is as wide as the wider
    // of the two, so the button's width is fixed by construction rather
    // than by a magic number that breaks in another language.
    gridArea: "1 / 1",
    display: "inline-flex",
    alignItems: "center",
    gap: 6,
    fontSize: 13.5,
    fontWeight: 600,
    lineHeight: 1,
    whiteSpace: "nowrap",
    pointerEvents: "none",
    // The background layers are positioned, so they paint above static
    // in-flow content — without this the accent pill covers the label
    // whenever the y transform is at rest. Grid items honor z-index
    // without needing a position.
    zIndex: 1,
  };

  return (
    <div
      style={{
        display: "flex",
        alignItems: "center",
        gap: 12,
        width: 300,
        padding: "12px 14px",
        borderRadius: 14,
        border: `1px solid ${tone(11)}`,
        background: tone(4),
      }}
    >
      <span
        aria-hidden
        style={{
          display: "grid",
          placeItems: "center",
          width: 38,
          height: 38,
          borderRadius: "50%",
          background: tint,
          color: "#ffffff",
          fontSize: 13,
          fontWeight: 650,
        }}
      >
        {initials}
      </span>

      <div style={{ flex: 1, minWidth: 0, lineHeight: 1.35 }}>
        <div style={{ fontSize: 13.5, fontWeight: 600 }}>{name}</div>
        <div style={{ fontSize: 12, opacity: 0.5 }}>{handle}</div>
      </div>

      <button
        type="button"
        onClick={toggle}
        aria-pressed={following}
        aria-label={`Follow ${name}`}
        style={{
          position: "relative",
          display: "grid",
          placeItems: "center",
          padding: "9px 15px",
          borderRadius: 999,
          border: 0,
          background: "transparent",
          color: "inherit",
          fontFamily: "inherit",
          cursor: "pointer",
        }}
      >
        {/* Two background layers rather than one animated color: the
            followed state is a `color-mix` of the inherited text color,
            which no interpolator can tween — crossfading two static
            layers keeps both states theme-adaptive. */}
        <motion.span
          aria-hidden
          initial={false}
          animate={{ opacity: following ? 0 : 1 }}
          transition={swap}
          style={{
            position: "absolute",
            inset: 0,
            borderRadius: 999,
            background: ACCENT,
          }}
        />
        <motion.span
          aria-hidden
          initial={false}
          animate={{ opacity: following ? 1 : 0 }}
          transition={swap}
          style={{
            position: "absolute",
            inset: 0,
            borderRadius: 999,
            background: tone(7),
            border: `1px solid ${tone(18)}`,
            boxSizing: "border-box",
          }}
        />

        <motion.span
          aria-hidden
          initial={false}
          animate={{ opacity: following ? 0 : 1, y: following ? -travel : 0 }}
          transition={swap}
          style={{ ...labelStyle, color: "#ffffff" }}
        >
          Follow
        </motion.span>

        <motion.span
          aria-hidden
          initial={false}
          animate={{ opacity: following ? 1 : 0, y: following ? 0 : travel }}
          transition={swap}
          style={{ ...labelStyle, color: "inherit" }}
        >
          <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
            <motion.path
              d="M3.2 8.3 6.4 11.5 12.8 4.6"
              stroke="currentColor"
              strokeWidth="1.9"
              strokeLinecap="round"
              strokeLinejoin="round"
              initial={false}
              animate={{ pathLength: following ? 1 : 0 }}
              transition={{
                duration: reduceMotion ? 0.12 : cfg.drawSeconds,
                ease: "easeOut",
                delay: following && !reduceMotion ? 0.05 : 0,
              }}
            />
          </svg>
          Following
        </motion.span>
      </button>
    </div>
  );
}

About this pattern

The failure mode of a follow control is layout, not motion: the label gets longer, the button grows, and every row after it shifts under the finger that just tapped. Here both labels occupy the same grid cell, so the button is always as wide as the longer of the two and nothing around it moves. The solid fill hands over to an outline, the old label leaves upward while the new one arrives from below at a constant size, and a check draws in behind it.

Following a profileSubscribing to a channelJoining a spaceWatching a repository

Where it shows up

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

  • 10:15
    Priya RamanTrail running · Gothenburg
    214Posts1,842Followers306Following
    Follow
    Priya Raman2hFinally got the trail loop under an hour. Four months of Tuesdays.
    12814
    Profile page

    State change reads through fill and label, never through size.

Related patterns