All patterns

Monthly Yearly Toggle

Switching the billing period slides one thumb and rolls every plan price together.

commercepremiumenergeticinteraction · finite · intermediate · ~0.6s
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.

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

/**
 * Vibary · Monthly Yearly Toggle
 *
 * Switching the billing period moves one thumb across the control and
 * rolls every price on the page at the same instant, so the plans stay
 * comparable while they change. The savings badge arrives a beat later,
 * once the new numbers are already legible.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed 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`, `plans`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type BillingPlan = {
  id: string;
  name: string;
  /** Small qualifier under the plan name. */
  detail: string;
  /** Formatted monthly price when billed monthly. */
  monthly: string;
  /** Formatted monthly price when billed yearly. */
  yearly: string;
};

export type MonthlyYearlyToggleProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Plans priced on both periods. */
  plans?: BillingPlan[];
  /** Label of the shorter period. */
  monthlyLabel?: string;
  /** Label of the longer period. */
  yearlyLabel?: string;
  /** Badge revealed on the longer period. */
  savingLabel?: string;
  /** Whether the longer period is chosen on first render. */
  initialYearly?: boolean;
  /** Accent for the thumb and the badge. */
  accent?: string;
  /** Fires with the chosen period. */
  onChange?: (yearly: boolean) => void;
};

type VariantConfig = {
  /** Spring the thumb rides across the control. */
  thumb: { type: "spring"; stiffness: number; damping: number };
  /** Spring a price rolls on. */
  roll: { type: "spring"; stiffness: number; damping: number };
  /** Gap between one price rolling and the following one. */
  stagger: number;
  /** Crossfade length for the labels and the badge. */
  fadeSeconds: number;
};

// Prices being compared have to be readable the instant they land, so
// nothing here overshoots far: damping ratios (damping / 2√stiffness)
// sit at or above 0.86. Variants differ in the size of the cascade and
// the pace of the thumb, never in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Every price at once, thumb almost instant. For a dense pricing grid.
  subtle: {
    thumb: { type: "spring", stiffness: 620, damping: 48 },
    roll: { type: "spring", stiffness: 620, damping: 48 },
    stagger: 0,
    fadeSeconds: 0.14,
  },
  // A short cascade down the plans. All-purpose.
  default: {
    thumb: { type: "spring", stiffness: 440, damping: 38 },
    roll: { type: "spring", stiffness: 440, damping: 38 },
    stagger: 0.05,
    fadeSeconds: 0.2,
  },
  // A longer cascade for a pricing page where the annual saving is the
  // headline.
  playful: {
    thumb: { type: "spring", stiffness: 300, damping: 31 },
    roll: { type: "spring", stiffness: 320, damping: 33 },
    stagger: 0.085,
    fadeSeconds: 0.24,
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one.
 *  The accent stays literal. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_PLANS: BillingPlan[] = [
  {
    id: "starter",
    name: "Starter",
    detail: "One workspace",
    monthly: "$12",
    yearly: "$9",
  },
  {
    id: "team",
    name: "Team",
    detail: "Up to twelve seats",
    monthly: "$29",
    yearly: "$23",
  },
  {
    id: "scale",
    name: "Scale",
    detail: "Unlimited seats",
    monthly: "$64",
    yearly: "$51",
  },
];

export default function MonthlyYearlyToggle({
  variant = "default",
  plans = DEFAULT_PLANS,
  monthlyLabel = "Monthly",
  yearlyLabel = "Yearly",
  savingLabel = "Save 20%",
  initialYearly = false,
  accent = "#7C7CF0",
  onChange,
}: MonthlyYearlyToggleProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  // Scoped so two of these on one page cannot share a travelling thumb.
  const thumbId = `${useId()}-thumb`;

  const [yearly, setYearly] = useState(initialYearly);

  const choose = (nextYearly: boolean) => {
    if (nextYearly === yearly) return;
    setYearly(nextYearly);
    onChange?.(nextYearly);
  };

  // Yearly is cheaper per month, so its prices arrive from above and
  // push the monthly ones out of the bottom of the slot.
  const travel = reduceMotion ? 0 : yearly ? -22 : 22;
  const periods: { label: string; isYearly: boolean }[] = [
    { label: monthlyLabel, isYearly: false },
    { label: yearlyLabel, isYearly: true },
  ];

  return (
    <div style={{ width: 278, fontSize: 13 }}>
      <div
        role="radiogroup"
        aria-label="Billing period"
        style={{
          position: "relative",
          display: "grid",
          gridTemplateColumns: "1fr 1fr",
          gap: 4,
          padding: 4,
          borderRadius: 11,
          background: tone(7),
        }}
      >
        {periods.map((period) => {
          const selected = period.isYearly === yearly;
          return (
            <button
              key={period.label}
              type="button"
              role="radio"
              aria-checked={selected}
              onClick={() => choose(period.isYearly)}
              style={{
                position: "relative",
                height: 32,
                display: "grid",
                placeItems: "center",
                fontSize: 12.5,
                fontWeight: 600,
                fontFamily: "inherit",
                color: "inherit",
                background: "transparent",
                border: 0,
                borderRadius: 8,
                cursor: "pointer",
              }}
            >
              {/* One thumb for the control. Sharing a layout id makes it
                  slide across rather than blink from one half to the
                  other; the labels above it never change size. */}
              {selected && (
                <motion.span
                  layoutId={thumbId}
                  transition={reduceMotion ? { duration: 0 } : cfg.thumb}
                  aria-hidden
                  style={{
                    position: "absolute",
                    inset: 0,
                    borderRadius: 8,
                    background: `color-mix(in srgb, ${accent} 16%, transparent)`,
                    border: `1px solid color-mix(in srgb, ${accent} 42%, transparent)`,
                  }}
                />
              )}
              <motion.span
                initial={false}
                animate={{ opacity: selected ? 1 : 0.55 }}
                transition={{ duration: cfg.fadeSeconds, ease: "easeOut" }}
                style={{ position: "relative" }}
              >
                {period.label}
              </motion.span>
            </button>
          );
        })}
      </div>

      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 8,
          height: 22,
          marginTop: 10,
        }}
      >
        {/* The badge fades and lifts a few pixels. It never scales — the
            sentence inside it has to stay readable while it arrives. */}
        <motion.span
          initial={false}
          animate={{
            opacity: yearly ? 1 : 0,
            y: yearly || reduceMotion ? 0 : 4,
          }}
          transition={{
            duration: cfg.fadeSeconds,
            ease: "easeOut",
            delay: yearly && !reduceMotion ? 0.14 : 0,
          }}
          style={{
            display: "inline-flex",
            alignItems: "center",
            gap: 5,
            padding: "3px 8px",
            borderRadius: 999,
            fontSize: 11,
            fontWeight: 600,
            color: accent,
            background: `color-mix(in srgb, ${accent} 14%, transparent)`,
          }}
        >
          <svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
            <path
              d="M6 1.6v8.8M6 10.4 2.9 7.3M6 10.4l3.1-3.1"
              stroke="currentColor"
              strokeWidth="1.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
          {savingLabel}
        </motion.span>
        <span style={{ marginLeft: "auto", fontSize: 11, opacity: 0.5 }}>
          {yearly ? "Billed once a year" : "Billed every month"}
        </span>
      </div>

      <div style={{ display: "grid", gap: 6, marginTop: 10 }}>
        {plans.map((plan, index) => (
          <div
            key={plan.id}
            style={{
              display: "flex",
              alignItems: "center",
              gap: 10,
              padding: "10px 12px",
              borderRadius: 11,
              background: tone(5),
              border: `1px solid ${tone(10)}`,
            }}
          >
            <span style={{ display: "grid", gap: 2, minWidth: 0 }}>
              <span style={{ fontSize: 12.5, fontWeight: 600 }}>
                {plan.name}
              </span>
              <span style={{ fontSize: 11, opacity: 0.5 }}>{plan.detail}</span>
            </span>

            <span
              style={{
                marginLeft: "auto",
                display: "flex",
                alignItems: "baseline",
                gap: 3,
              }}
            >
              {/* Every price rolls at the same moment, in the same
                  direction, at one type size — that is what keeps the
                  plans comparable while the numbers change. */}
              <span
                style={{
                  display: "grid",
                  justifyItems: "end",
                  height: 24,
                  overflow: "hidden",
                }}
              >
                <AnimatePresence initial={false}>
                  <motion.span
                    key={yearly ? plan.yearly : plan.monthly}
                    initial={{ y: travel, opacity: 0 }}
                    animate={{ y: 0, opacity: 1 }}
                    exit={{ y: -travel, opacity: 0 }}
                    transition={{
                      y: reduceMotion
                        ? { duration: 0 }
                        : { ...cfg.roll, delay: index * cfg.stagger },
                      opacity: {
                        duration: cfg.fadeSeconds,
                        ease: "easeOut",
                        delay: reduceMotion ? 0 : index * cfg.stagger,
                      },
                    }}
                    style={{
                      gridArea: "1 / 1",
                      fontSize: 18,
                      fontWeight: 650,
                      lineHeight: "24px",
                      letterSpacing: -0.2,
                      fontVariantNumeric: "tabular-nums",
                    }}
                  >
                    {yearly ? plan.yearly : plan.monthly}
                  </motion.span>
                </AnimatePresence>
              </span>
              <span style={{ fontSize: 11, opacity: 0.5 }}>/mo</span>
            </span>
          </div>
        ))}
      </div>
    </div>
  );
}

About this pattern

Plans are only comparable if they change together. One thumb slides across the period control on a shared layout animation, and every price rolls in the same direction on the same beat — a short cascade down the list, at one constant type size — so the reader is never comparing a settled number against a moving one. The savings badge arrives a beat after the prices have landed, which is the only order in which it can be read. Nothing scales: a price that grows to make a point is a price nobody trusts.

Pricing page billing periodSubscription upgrade screenAnnual versus monthly comparisonSeat pricing with a term discount

Where it shows up

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

  • BillingLast 30 days
    Invoice 4821$169.65Paid
    Invoice 4809$412.00Paid
    Invoice 4794$88.20Due Jun 30
    Invoice 4780$1,204.00Paid
    Billing page

    A period control above the plan columns restates every seat price at once.

Related patterns