All patterns

Goal Picker

Chosen goal tiles paint themselves and take a mark, and the continue action rises the moment the first one is picked.

onboardingfriendlyenergeticinteraction · 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.

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

/**
 * Vibary · Goal Picker
 *
 * A grid of goals where any number can be chosen. Each pick paints its
 * tile and settles a mark into the corner; the continue action rises
 * from below the moment the first one is taken, and leaves again if
 * everything is cleared.
 *
 * 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`, `goals`,
 * `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type Goal = {
  id: string;
  label: string;
};

export type GoalPickerMultiProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  question?: string;
  hint?: string;
  /** Your own goals. The embedded sample is used when omitted. */
  goals?: Goal[];
  /** Selection and button color. */
  accent?: string;
  /** Fires with every selected id. */
  onChange?: (ids: string[]) => void;
  /** Fires when the selection is confirmed. */
  onContinue?: (ids: string[]) => void;
};

type VariantConfig = {
  /** px a chosen tile lifts. */
  lift: number;
  tileSpring: { 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 action rises from. */
  actionRise: number;
  actionSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: damping ratios (damping / 2√stiffness) stay at or above
// 0.8. Tiles carry labels, so they lift and tint but never scale; only
// the corner mark, which is a shape, is allowed to grow. Variants change
// lift and pace, never bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A whisper of lift. For a question asked halfway through a flow.
  subtle: {
    lift: 1,
    tileSpring: { type: "spring", stiffness: 560, damping: 46 },
    markFrom: 0.7,
    markSpring: { type: "spring", stiffness: 560, damping: 40 },
    actionRise: 8,
    actionSpring: { type: "spring", stiffness: 520, damping: 44 },
  },
  // Each pick is felt, and the action clearly arrives. All-purpose.
  default: {
    lift: 2,
    tileSpring: { type: "spring", stiffness: 460, damping: 40 },
    markFrom: 0.55,
    markSpring: { type: "spring", stiffness: 480, damping: 36 },
    actionRise: 14,
    actionSpring: { type: "spring", stiffness: 420, damping: 38 },
  },
  // A firmer press and a longer rise, for a single-screen question.
  playful: {
    lift: 3,
    tileSpring: { type: "spring", stiffness: 380, damping: 34 },
    markFrom: 0.45,
    markSpring: { type: "spring", stiffness: 400, damping: 33 },
    actionRise: 20,
    actionSpring: { type: "spring", stiffness: 340, damping: 33 },
  },
};

// Short on purpose: two labels per row, each on one line, so no tile in
// the grid is ever taller than the one beside it.
const SAMPLE_GOALS: Goal[] = [
  { id: "plan", label: "Plan projects" },
  { id: "track", label: "Track tasks" },
  { id: "notes", label: "Keep notes" },
  { id: "docs", label: "Share docs" },
  { id: "review", label: "Review work" },
  { id: "report", label: "Send updates" },
];

/** 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 GoalPickerMulti({
  variant = "default",
  question = "What do you want to get done?",
  hint = "Pick as many as apply.",
  goals = SAMPLE_GOALS,
  accent = "#5B5BD6",
  onChange,
  onContinue,
}: GoalPickerMultiProps) {
  const [picked, setPicked] = useState<string[]>([]);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const toggle = (id: string) => {
    setPicked((current) => {
      const next = current.includes(id)
        ? current.filter((entry) => entry !== id)
        : [...current, id];
      onChange?.(next);
      return next;
    });
  };

  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: "5px 0 13px", fontSize: 11.5, opacity: 0.55 }}>{hint}</p>

      <div
        role="group"
        aria-label={question}
        style={{
          display: "grid",
          gridTemplateColumns: "1fr 1fr",
          gap: 8,
        }}
      >
        {goals.map((goal) => {
          const on = picked.includes(goal.id);
          return (
            <motion.button
              key={goal.id}
              type="button"
              role="checkbox"
              aria-checked={on}
              onClick={() => toggle(goal.id)}
              animate={{ y: reduceMotion ? 0 : on ? -cfg.lift : 0 }}
              transition={reduceMotion ? { duration: 0.14 } : cfg.tileSpring}
              style={{
                position: "relative",
                display: "flex",
                alignItems: "center",
                gap: 8,
                padding: "12px 11px",
                fontFamily: "inherit",
                color: "inherit",
                textAlign: "left",
                border: `1px solid ${tone(13)}`,
                borderRadius: 12,
                background: tone(4),
                cursor: "pointer",
              }}
            >
              {/* Selection is painted by fading a layer in, not by
                  animating a color: theme-adaptive neutrals are
                  color-mix() values, which no engine interpolates. */}
              <motion.span
                aria-hidden
                initial={false}
                animate={{ opacity: on ? 1 : 0 }}
                transition={{ duration: 0.18, ease: "easeOut" }}
                style={{
                  position: "absolute",
                  inset: -1,
                  borderRadius: 12,
                  border: `1px solid ${accent}`,
                  background: `color-mix(in srgb, ${accent} 12%, transparent)`,
                  pointerEvents: "none",
                }}
              />

              <span
                style={{
                  position: "relative",
                  color: on ? accent : "inherit",
                  opacity: on ? 1 : 0.6,
                  lineHeight: 0,
                }}
              >
                <GoalIcon id={goal.id} />
              </span>
              <span style={{ position: "relative", fontSize: 12, fontWeight: 600 }}>
                {goal.label}
              </span>

              <AnimatePresence initial={false}>
                {on && (
                  <motion.span
                    key="mark"
                    aria-hidden
                    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 } : cfg.markSpring
                    }
                    style={{
                      position: "absolute",
                      top: -6,
                      right: -6,
                      width: 18,
                      height: 18,
                      borderRadius: 999,
                      display: "grid",
                      placeItems: "center",
                      background: accent,
                      boxShadow: "0 2px 6px rgba(0,0,0,0.16)",
                    }}
                  >
                    <svg width="10" height="10" viewBox="0 0 12 12" fill="none">
                      <path
                        d="M2.6 6.3 5 8.7l4.4-5"
                        stroke="#ffffff"
                        strokeWidth="2"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                    </svg>
                  </motion.span>
                )}
              </AnimatePresence>
            </motion.button>
          );
        })}
      </div>

      {/* The row is reserved, so the grid above never shifts when the
          action arrives — it rises into space that was already there. */}
      <div style={{ height: 42, marginTop: 12, overflow: "hidden" }}>
        <AnimatePresence>
          {picked.length > 0 && (
            <motion.button
              key="continue"
              type="button"
              onClick={() => onContinue?.(picked)}
              initial={
                reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.actionRise }
              }
              animate={{ opacity: 1, y: 0 }}
              exit={{
                opacity: 0,
                y: reduceMotion ? 0 : cfg.actionRise,
                transition: { duration: 0.16, ease: "easeIn" },
              }}
              transition={
                reduceMotion ? { duration: 0.18, ease: "easeOut" } : cfg.actionSpring
              }
              style={{
                width: "100%",
                padding: "11px 16px",
                fontSize: 13,
                fontWeight: 650,
                fontFamily: "inherit",
                color: "#ffffff",
                background: accent,
                border: "none",
                borderRadius: 11,
                cursor: "pointer",
              }}
            >
              {picked.length === 1
                ? "Continue with 1 goal"
                : `Continue with ${picked.length} goals`}
            </motion.button>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

/** Inline SVG marks — no asset, no icon dependency. */
function GoalIcon({ id }: { id: string }) {
  const common = {
    width: 15,
    height: 15,
    viewBox: "0 0 16 16",
    fill: "none",
    "aria-hidden": true,
  } as const;

  if (id === "track") {
    return (
      <svg {...common}>
        <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.4" />
        <path d="M8 4.6V8l2.4 1.6" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
      </svg>
    );
  }
  if (id === "notes") {
    return (
      <svg {...common}>
        <path
          d="M3.4 3.2h9.2v6.4l-3 3H3.4z"
          stroke="currentColor"
          strokeWidth="1.4"
          strokeLinejoin="round"
        />
        <path d="M6 6.2h4M6 8.6h2.6" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
      </svg>
    );
  }
  if (id === "docs") {
    return (
      <svg {...common}>
        <path
          d="M8 10.4V3.2M8 3.2 5.6 5.6M8 3.2l2.4 2.4M3.2 9.8v2a1.2 1.2 0 0 0 1.2 1.2h7.2a1.2 1.2 0 0 0 1.2-1.2v-2"
          stroke="currentColor"
          strokeWidth="1.4"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
    );
  }
  if (id === "review") {
    return (
      <svg {...common}>
        <circle cx="7.2" cy="7.2" r="4.2" stroke="currentColor" strokeWidth="1.4" />
        <path d="M10.4 10.4 13.2 13.2" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
      </svg>
    );
  }
  if (id === "report") {
    return (
      <svg {...common}>
        <path
          d="M3.4 12.6V8.2M7 12.6V3.4M10.6 12.6V6.2"
          stroke="currentColor"
          strokeWidth="1.6"
          strokeLinecap="round"
        />
      </svg>
    );
  }
  return (
    <svg {...common}>
      <rect x="2.6" y="3.4" width="10.8" height="9.4" rx="2" stroke="currentColor" strokeWidth="1.4" />
      <path d="M2.6 6.4h10.8M5.6 2.4v2M10.4 2.4v2" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
    </svg>
  );
}

About this pattern

The multi-select question, where the answer can be any number of things. Each tile lifts a pixel or two and fades an accent layer in over itself — a layer rather than an animated background-color, because theme-adaptive neutrals are color-mix() values and no engine can interpolate those — while a mark springs into its corner. The label never moves or resizes. The payoff is the action: it rises out of a row whose height is already reserved, so the grid above it cannot shift, and it leaves the same way if every tile is cleared. Nothing in the grid scales, because everything in the grid is text.

Goal selectionInterest pickerMulti-select preferencesTopic chooser

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

    Selectable tiles that mark themselves while the continue action waits below.

Related patterns