Follow Button Swap
Follow becomes Following in place — the button never changes width.
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, 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Profile page
State change reads through fill and label, never through size.