All patterns

Persona Select

Picking a role lifts that card while the others step back, and a line below says what the answer changed.

onboardingpremiumfriendlyinteraction · finite · starter · ~0.4s
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.

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

/**
 * Vibary · Persona Select
 *
 * Choosing a role lifts that card off the stack while the others recede,
 * a mark settles into its corner, and a line appears underneath saying
 * what the answer changed.
 *
 * Self-contained: depends only on `react` and `motion`. Neutrals are
 * mixed from the inherited text color, so it reads on light and dark
 * pages alike. Works with zero props; tune via `variant`, `options`,
 * `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type PersonaOption = {
  id: string;
  label: string;
  detail: string;
  /** Which workspace the choice sets up. */
  outcome: string;
};

export type PersonaSelectCardProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  question?: string;
  hint?: string;
  /** Your own roles. The embedded sample is used when omitted. */
  options?: PersonaOption[];
  /** Selection color. */
  accent?: string;
  /** Fires with the id of the role that was picked. */
  onSelect?: (id: string) => void;
  /** Fires when the choice is confirmed. */
  onContinue?: (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 the mark grows from — a shape, so it may scale. */
  markFrom: number;
  markSpring: { type: "spring"; stiffness: number; damping: number };
  /** px the follow-up line rises. */
  footerRise: number;
};

// Quality rule: damping ratios (damping / 2√stiffness) stay at or above
// 0.8. These cards carry text, so they lift and fade — they never scale
// and never rock. Variants change lift and pace, never bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A hairline of lift. For a question asked mid-flow.
  subtle: {
    lift: 2,
    recede: 0.55,
    cardSpring: { type: "spring", stiffness: 540, damping: 46 },
    markFrom: 0.7,
    markSpring: { type: "spring", stiffness: 560, damping: 40 },
    footerRise: 6,
  },
  // Enough separation to see the stack reorder itself. All-purpose.
  default: {
    lift: 4,
    recede: 0.44,
    cardSpring: { type: "spring", stiffness: 440, damping: 40 },
    markFrom: 0.6,
    markSpring: { type: "spring", stiffness: 480, damping: 36 },
    footerRise: 10,
  },
  // A decisive lift, for the one question that shapes the whole account.
  playful: {
    lift: 6,
    recede: 0.36,
    cardSpring: { type: "spring", stiffness: 360, damping: 34 },
    markFrom: 0.5,
    markSpring: { type: "spring", stiffness: 400, damping: 33 },
    footerRise: 14,
  },
};

const SAMPLE_OPTIONS: PersonaOption[] = [
  {
    id: "design",
    label: "Design",
    detail: "Boards, files and review rounds",
    outcome: "a design workspace",
  },
  {
    id: "engineering",
    label: "Engineering",
    detail: "Issues, branches and releases",
    outcome: "an engineering workspace",
  },
  {
    id: "operations",
    label: "Operations",
    detail: "Schedules, vendors and budgets",
    outcome: "an operations workspace",
  },
];

/** 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 PersonaSelectCard({
  variant = "default",
  question = "What do you work on?",
  hint = "We use this to lay out your first workspace. It can be changed later.",
  options = SAMPLE_OPTIONS,
  accent = "#5B5BD6",
  onSelect,
  onContinue,
}: PersonaSelectCardProps) {
  const [picked, setPicked] = useState<string | null>(null);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const chosen = options.find((option) => option.id === picked) ?? null;

  const select = (id: string) => {
    setPicked((current) => (current === id ? null : id));
    onSelect?.(id);
  };

  return (
    <div
      style={{
        width: 320,
        boxSizing: "border-box",
        padding: 18,
        borderRadius: 18,
        border: `1px solid ${tone(12)}`,
        background: tone(5),
      }}
    >
      <div style={{ fontSize: 15, fontWeight: 680, letterSpacing: -0.2 }}>
        {question}
      </div>
      <p style={{ margin: "6px 0 14px", fontSize: 11.5, lineHeight: 1.5, opacity: 0.55 }}>
        {hint}
      </p>

      <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
        {options.map((option) => {
          const isPicked = option.id === picked;
          const dimmed = picked !== null && !isPicked;

          return (
            <motion.button
              key={option.id}
              type="button"
              onClick={() => select(option.id)}
              aria-pressed={isPicked}
              // The chosen card lifts and the rest step back. Opacity and
              // a few pixels of travel only: these cards are text, and
              // scaling a label to say "selected" is never worth it.
              animate={{
                y: reduceMotion ? 0 : isPicked ? -cfg.lift : dimmed ? 1 : 0,
                opacity: dimmed ? cfg.recede : 1,
              }}
              transition={
                reduceMotion
                  ? { duration: 0.16, ease: "easeOut" }
                  : {
                      default: cfg.cardSpring,
                      opacity: { duration: 0.2, ease: "easeOut" },
                    }
              }
              style={{
                position: "relative",
                display: "flex",
                alignItems: "center",
                gap: 11,
                width: "100%",
                padding: "11px 12px",
                fontFamily: "inherit",
                color: "inherit",
                textAlign: "left",
                border: `1px solid ${tone(13)}`,
                borderRadius: 13,
                background: tone(4),
                cursor: "pointer",
              }}
            >
              {/* Selection is painted by fading in a layer, not by
                  animating a color: theme-adaptive neutrals are
                  color-mix() values and no engine can interpolate those. */}
              <motion.span
                aria-hidden
                initial={false}
                animate={{ opacity: isPicked ? 1 : 0 }}
                transition={{ duration: 0.2, ease: "easeOut" }}
                style={{
                  position: "absolute",
                  inset: -1,
                  borderRadius: 13,
                  border: `1px solid ${accent}`,
                  background: `color-mix(in srgb, ${accent} 12%, transparent)`,
                  pointerEvents: "none",
                }}
              />
              <span
                style={{
                  position: "relative",
                  flex: "0 0 auto",
                  width: 30,
                  height: 30,
                  borderRadius: 9,
                  display: "grid",
                  placeItems: "center",
                  color: isPicked ? accent : "inherit",
                  opacity: isPicked ? 1 : 0.65,
                  background: tone(8),
                }}
              >
                <RoleIcon id={option.id} />
              </span>

              <span style={{ position: "relative", minWidth: 0 }}>
                <span style={{ display: "block", fontSize: 13, fontWeight: 650 }}>
                  {option.label}
                </span>
                <span
                  style={{
                    display: "block",
                    marginTop: 2,
                    fontSize: 11,
                    lineHeight: 1.4,
                    opacity: 0.55,
                  }}
                >
                  {option.detail}
                </span>
              </span>

              <span
                aria-hidden
                style={{
                  position: "relative",
                  marginLeft: "auto",
                  width: 20,
                  height: 20,
                  display: "grid",
                  placeItems: "center",
                }}
              >
                <AnimatePresence initial={false}>
                  {isPicked && (
                    <motion.span
                      key="mark"
                      initial={
                        reduceMotion
                          ? { opacity: 0 }
                          : { opacity: 0, scale: cfg.markFrom }
                      }
                      animate={{ opacity: 1, scale: 1 }}
                      exit={{
                        opacity: 0,
                        scale: reduceMotion ? 1 : cfg.markFrom,
                        transition: { duration: 0.12, ease: "easeIn" },
                      }}
                      transition={
                        reduceMotion
                          ? { duration: 0.14, ease: "easeOut" }
                          : cfg.markSpring
                      }
                      style={{
                        width: 20,
                        height: 20,
                        borderRadius: 999,
                        display: "grid",
                        placeItems: "center",
                        background: accent,
                      }}
                    >
                      <svg width="11" height="11" viewBox="0 0 12 12" fill="none">
                        <path
                          d="M2.6 6.3 5 8.7l4.4-5"
                          stroke="#ffffff"
                          strokeWidth="1.9"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                        />
                      </svg>
                    </motion.span>
                  )}
                </AnimatePresence>
              </span>
            </motion.button>
          );
        })}
      </div>

      {/* The flow adapting to the answer is the payoff, so it gets its
          own beat rather than appearing with the selection. */}
      <div style={{ height: 46, marginTop: 12 }}>
        <AnimatePresence initial={false}>
          {chosen && (
            <motion.div
              key="footer"
              initial={
                reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.footerRise }
              }
              animate={{ opacity: 1, y: 0 }}
              exit={{
                opacity: 0,
                y: reduceMotion ? 0 : cfg.footerRise * 0.5,
                transition: { duration: 0.14, ease: "easeIn" },
              }}
              transition={{
                duration: reduceMotion ? 0.16 : 0.26,
                delay: reduceMotion ? 0 : 0.08,
                ease: "easeOut",
              }}
              style={{
                display: "flex",
                alignItems: "center",
                gap: 10,
                padding: "0 2px",
              }}
            >
              <span style={{ fontSize: 11.5, lineHeight: 1.4, opacity: 0.6 }}>
                {`We'll set up ${chosen.outcome} to start.`}
              </span>
              <button
                type="button"
                onClick={() => onContinue?.(chosen.id)}
                style={{
                  marginLeft: "auto",
                  flex: "0 0 auto",
                  padding: "9px 14px",
                  fontSize: 12.5,
                  fontWeight: 650,
                  fontFamily: "inherit",
                  color: "#ffffff",
                  background: accent,
                  border: "none",
                  borderRadius: 10,
                  cursor: "pointer",
                }}
              >
                Continue
              </button>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

/** Inline SVG role marks — no asset, no icon dependency. */
function RoleIcon({ id }: { id: string }) {
  if (id === "engineering") {
    return (
      <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
        <path
          d="M6 4.5 2.6 8 6 11.5M10 4.5 13.4 8 10 11.5"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
    );
  }
  if (id === "operations") {
    return (
      <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
        <rect x="2.4" y="3.4" width="11.2" height="10.2" rx="2.2" stroke="currentColor" strokeWidth="1.4" />
        <path
          d="M5.4 2.2v2.4M10.6 2.2v2.4M2.4 7h11.2"
          stroke="currentColor"
          strokeWidth="1.4"
          strokeLinecap="round"
        />
      </svg>
    );
  }
  return (
    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
      <path
        d="M8 2.2 12.4 6 8 13.8 3.6 6z"
        stroke="currentColor"
        strokeWidth="1.4"
        strokeLinejoin="round"
      />
      <path d="M3.6 6h8.8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
    </svg>
  );
}

About this pattern

The role question every setup flow asks, answered visibly. The chosen card rises a few pixels on a spring and takes an accent border; the ones not chosen fall to under half opacity and settle a pixel lower, which is what makes the stack read as reordered rather than merely recolored. A mark scales into the corner — it is a shape, so it is allowed to — while the label beside it never moves or resizes. The payoff gets its own beat: a line underneath naming the workspace the answer will build, with the continue action beside it, on a reserved row so nothing below shifts when it appears.

Role selectionUse-case questionSegmentation stepPreference picker

Where it shows up

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

  • Finish setting upTwo left before you can take payments
    Verify your email
    Add a business address
    Connect a bank account
    Turn on two-factor
    Connect bank
    Setup checklist

    Choice rows that mark themselves and reveal a continue action once answered.

Related patterns