All patterns

Plan Select Highlight

The pressed tier lifts and takes the accent while the feature list below resolves its marks one after another.

onboardingpremiumelegantinteraction · finite · intermediate · ~0.7s
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.

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

/**
 * Vibary · Plan Select Highlight
 *
 * Choosing a tier and watching what it actually buys resolve underneath.
 * The chosen card lifts a few pixels and takes the accent while the
 * others step back, and the feature list below settles its marks one
 * after another — included, then not included — so the answer arrives as
 * a short sentence rather than as an instant substitution.
 *
 * The feature wording never changes and never moves. Only the marks
 * resolve, which means the eye tracks one column of small shapes instead
 * of re-reading four lines of type every time a plan is pressed.
 *
 * Self-contained: depends only on `react` and `motion`. Cards and rows
 * are mixed from the inherited text color; the accent is semantic and
 * stays literal.
 * Works with zero props; tune via `variant`, `plans`, `features`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type Plan = {
  id: string;
  name: string;
  price: string;
  /** Small line under the price. */
  note: string;
  /** One flag per entry in `features`, in the same order. */
  includes: boolean[];
};

export type PlanSelectHighlightProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Heading above the cards. */
  heading?: string;
  /** Small note beside the heading. */
  billingNote?: string;
  /** Your own tiers. The embedded sample is used when omitted. */
  plans?: Plan[];
  /** Row labels of the feature list, top to bottom. */
  features?: string[];
  /** Which plan starts selected. Defaults to the middle one. */
  initialPlanId?: string;
  /** Accent colour. Semantic, so it stays literal. */
  accent?: string;
  /** Fires with the id of the plan that was pressed. */
  onSelect?: (id: string) => void;
};

type VariantConfig = {
  /** px the chosen card lifts. */
  lift: number;
  /** Opacity the unchosen cards fall back to. */
  recede: number;
  cardSpring: { type: "spring"; stiffness: number; damping: number };
  /** Scale each mark grows from — marks are shapes, so they may scale. */
  markFrom: number;
  markSpring: { type: "spring"; stiffness: number; damping: number };
  /** Gap between one mark resolving and the next. */
  stagger: number;
};

// Damping ratios run 0.86 and up, so a card lifts once and holds. The
// price is type and never scales; only the marks, which are shapes, are
// allowed to grow into place. Variants change lift and pace, never the
// number of rebounds.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A hairline of separation. For a plan step inside a longer flow.
  subtle: {
    lift: 2,
    recede: 0.6,
    cardSpring: { type: "spring", stiffness: 560, damping: 46 },
    markFrom: 0.72,
    markSpring: { type: "spring", stiffness: 620, damping: 42 },
    stagger: 0.035,
  },
  // The all-purpose setting: the list can be read as it resolves.
  default: {
    lift: 4,
    recede: 0.48,
    cardSpring: { type: "spring", stiffness: 440, damping: 38 },
    markFrom: 0.55,
    markSpring: { type: "spring", stiffness: 480, damping: 38 },
    stagger: 0.055,
  },
  // A decisive lift and a slower list, for a dedicated pricing screen.
  playful: {
    lift: 7,
    recede: 0.4,
    cardSpring: { type: "spring", stiffness: 360, damping: 32 },
    markFrom: 0.42,
    markSpring: { type: "spring", stiffness: 400, damping: 34 },
    stagger: 0.08,
  },
};

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

const SAMPLE_FEATURES = [
  "Unlimited projects",
  "Shared workspaces",
  "Priority support",
  "Audit log and SSO",
];

const SAMPLE_PLANS: Plan[] = [
  {
    id: "starter",
    name: "Starter",
    price: "$0",
    note: "Solo",
    includes: [false, true, false, false],
  },
  {
    id: "team",
    name: "Team",
    price: "$12",
    note: "Per member",
    includes: [true, true, true, false],
  },
  {
    id: "scale",
    name: "Scale",
    price: "$29",
    note: "Per member",
    includes: [true, true, true, true],
  },
];

export default function PlanSelectHighlight({
  variant = "default",
  heading = "Choose a plan",
  billingNote = "Billed monthly",
  plans = SAMPLE_PLANS,
  features = SAMPLE_FEATURES,
  initialPlanId,
  accent = "#4338CA",
  onSelect,
}: PlanSelectHighlightProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const still = !!reduceMotion;

  const fallback = plans[Math.min(1, plans.length - 1)]?.id ?? "";
  const [selected, setSelected] = useState(initialPlanId ?? fallback);
  const chosen = plans.find((plan) => plan.id === selected) ?? plans[0];

  const choose = (id: string) => {
    setSelected(id);
    onSelect?.(id);
  };

  return (
    <div style={{ width: 316, display: "flex", flexDirection: "column", gap: 12 }}>
      <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
        <span style={{ fontSize: 14, fontWeight: 670, letterSpacing: "-0.015em" }}>
          {heading}
        </span>
        <span style={{ marginLeft: "auto", fontSize: 10.5, color: tone(46) }}>
          {billingNote}
        </span>
      </div>

      <div style={{ display: "flex", gap: 10 }}>
        {plans.map((plan) => {
          const isChosen = plan.id === selected;
          return (
            <motion.button
              key={plan.id}
              type="button"
              onClick={() => choose(plan.id)}
              aria-pressed={isChosen}
              animate={{
                y: still ? 0 : isChosen ? -cfg.lift : 0,
                opacity: isChosen ? 1 : cfg.recede,
              }}
              transition={
                still
                  ? { duration: 0.16, ease: "easeOut" }
                  : {
                      default: cfg.cardSpring,
                      opacity: { duration: 0.2, ease: "easeOut" },
                    }
              }
              style={{
                position: "relative",
                flex: 1,
                minWidth: 0,
                display: "flex",
                flexDirection: "column",
                alignItems: "flex-start",
                gap: 3,
                padding: "11px 11px 12px",
                borderRadius: 13,
                border: `1px solid ${tone(13)}`,
                background: tone(4),
                color: "inherit",
                fontFamily: "inherit",
                textAlign: "left",
                cursor: "pointer",
              }}
            >
              {/* Selection is a layer fading in over the resting card,
                  not a colour being animated: the resting border is a
                  color-mix() value and cannot be interpolated. */}
              <motion.span
                aria-hidden
                initial={false}
                animate={{ opacity: isChosen ? 1 : 0 }}
                transition={{ duration: 0.2, ease: "easeOut" }}
                style={{
                  position: "absolute",
                  inset: -1,
                  borderRadius: 13,
                  border: `1.5px solid ${accent}`,
                  background: `color-mix(in srgb, ${accent} 9%, transparent)`,
                  pointerEvents: "none",
                }}
              />
              <span
                style={{
                  position: "relative",
                  fontSize: 10.5,
                  fontWeight: 640,
                  letterSpacing: "0.03em",
                  textTransform: "uppercase",
                  color: isChosen ? accent : tone(54),
                }}
              >
                {plan.name}
              </span>
              <span
                style={{
                  position: "relative",
                  fontSize: 19,
                  fontWeight: 690,
                  letterSpacing: "-0.03em",
                  lineHeight: 1.1,
                }}
              >
                {plan.price}
              </span>
              <span style={{ position: "relative", fontSize: 9.5, color: tone(46) }}>
                {plan.note}
              </span>
            </motion.button>
          );
        })}
      </div>

      <div
        style={{
          display: "flex",
          flexDirection: "column",
          gap: 2,
          padding: "10px 12px",
          borderRadius: 13,
          border: `1px solid ${tone(11)}`,
          background: tone(4),
        }}
      >
        {features.map((feature, index) => {
          const included = chosen?.includes[index] ?? false;
          return (
            <div
              key={feature}
              style={{
                display: "flex",
                alignItems: "center",
                gap: 9,
                height: 24,
              }}
            >
              {/* Remounted on every change of plan, which replays the
                  stagger without a single piece of timing state. The
                  wording beside it is untouched and never moves. */}
              <motion.span
                key={`${selected}-${index}`}
                aria-hidden
                initial={
                  still
                    ? { opacity: 0 }
                    : { opacity: 0, scale: cfg.markFrom }
                }
                animate={{ opacity: 1, scale: 1 }}
                transition={
                  still
                    ? { duration: 0.16, ease: "easeOut" }
                    : { ...cfg.markSpring, delay: cfg.stagger * index }
                }
                style={{
                  flex: "none",
                  display: "grid",
                  placeItems: "center",
                  width: 16,
                  height: 16,
                  borderRadius: 999,
                  background: included ? accent : "transparent",
                  boxShadow: included ? "none" : `inset 0 0 0 1.3px ${tone(20)}`,
                }}
              >
                {included ? (
                  <svg width="9" height="9" viewBox="0 0 12 12" fill="none">
                    <path
                      d="M2.6 6.2 4.9 8.5 9.4 3.7"
                      stroke="#FFFFFF"
                      strokeWidth="1.9"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                  </svg>
                ) : (
                  <span
                    style={{
                      width: 5,
                      height: 1.4,
                      borderRadius: 1,
                      background: tone(30),
                    }}
                  />
                )}
              </motion.span>

              <motion.span
                initial={false}
                animate={{ opacity: included ? 1 : 0.42 }}
                transition={{
                  duration: still ? 0.16 : 0.26,
                  delay: still ? 0 : cfg.stagger * index,
                  ease: "easeOut",
                }}
                style={{ fontSize: 11.5, fontWeight: 560 }}
              >
                {feature}
              </motion.span>
            </div>
          );
        })}
      </div>

      <div style={{ fontSize: 10.5, color: tone(44) }}>
        {chosen
          ? `${chosen.name} · ${chosen.includes.filter(Boolean).length} of ${features.length} included`
          : ""}
      </div>
    </div>
  );
}

About this pattern

Picking a tier and watching what it actually buys resolve underneath. The chosen card lifts a few pixels and the others step back, then the feature list settles its marks in sequence — included, then not included — so the answer arrives as a short sentence rather than as an instant substitution. The wording of the features never changes and never moves; only the small shapes beside it resolve, which means the eye tracks one column instead of re-reading four lines of type on every press. Prices are type and never scale, and the accent on the chosen card is a layer fading in over the resting surface rather than a colour being animated, because the resting border is a theme-derived color-mix() value that no engine can interpolate. The stagger is replayed by remounting the marks on a key, so the pattern carries no timing state at all.

Trial signup pricing stepUpgrade tier chooserSeat or storage tier pickerMembership level selection

Where it shows up

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

  • Set up your workspaceStep 2 of 4
    What should we call it?
    Ridgeline
    Who else is joining?
    3 invited
    Next
    Onboarding flow

    Choosing a bundle and seeing what the choice contains settle beneath it.

Related patterns