Goal Picker
Chosen goal tiles paint themselves and take a mark, and the continue action rises the moment the first one is picked.
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 } 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Setup checklist
Selectable tiles that mark themselves while the continue action waits below.
Related patterns
- Invite Team SendEach address leaves the compose box and travels into a waiting seat, then marks itself sent.
- Sample Data PopulateExample figures sweep into an empty report so a new account can see what the product does before it has data of its own.
- Seed First ProjectThe blank panel steps aside and a starter project builds itself in its place, task by task.